]> 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:

60fcd99f17ee0b0d0ef5523567daf5a3cb34d1ed
[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.20130829'
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 [<repo>]      Show statuses of all/one 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         mkdir -p "$VCSH_BASE" || fatal "could not create '$VCSH_BASE'" 50
218         cd "$VCSH_BASE" || fatal "could not enter '$VCSH_BASE'" 11
219         git init
220         upgrade
221         hook post-init
222 }
223
224 list() {
225         for repo in "$VCSH_REPO_D"/*.git; do
226                 [ -d "$repo" ] && [ -r "$repo" ] && echo $(basename "$repo" .git)
227         done
228 }
229
230 get_files() {
231         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
232         git ls-files
233 }
234
235 list_tracked() {
236         for VCSH_REPO_NAME in $(list); do
237                 get_files
238         done | sort -u
239 }
240
241 list_tracked_by() {
242         use
243         git ls-files | sort -u
244 }
245
246 pull() {
247         hook pre-pull
248         for VCSH_REPO_NAME in $(list); do
249                 echo -n "$VCSH_REPO_NAME: "
250                 export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
251                 use
252                 git pull
253                 echo
254         done
255         hook post-pull
256 }
257
258 push() {
259         hook pre-push
260         for VCSH_REPO_NAME in $(list); do
261                 echo -n "$VCSH_REPO_NAME: "
262                 export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
263                 use
264                 git push
265                 echo
266         done
267         hook post-push
268 }
269
270 retire() {
271         unset VCSH_DIRECTORY
272 }
273
274 rename() {
275         git_dir_exists
276         [ -d "$GIT_DIR_NEW" ] && fatal "'$GIT_DIR_NEW' exists" 54
277         mv -f "$GIT_DIR" "$GIT_DIR_NEW" || fatal "Could not mv '$GIT_DIR' '$GIT_DIR_NEW'" 52
278
279         # Now that the repository has been renamed, we need to fix up its configuration
280         # Overwrite old name..
281         GIT_DIR="$GIT_DIR_NEW"
282         $VCSH_REPO_NAME="$VCSH_REPO_NAME_NEW"
283         # ..and clobber all old configuration
284         upgrade
285 }
286
287 run() {
288         hook pre-run
289         use
290         "$@"
291         hook post-run
292 }
293
294 status() {
295         if [ ! "x$VCSH_REPO_NAME" = "x" ]; then
296                 export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
297                 use
298                 git status --short --untracked-files='no'
299         else
300                 for VCSH_REPO_NAME in $(list); do
301                         echo "$VCSH_REPO_NAME:"
302                         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
303                         use
304                         git status --short --untracked-files='no'
305                         echo
306                 done
307         fi
308 }
309
310 upgrade() {
311         hook pre-upgrade
312         # fake-bare repositories are not bare, actually. Set this to false
313         # because otherwise Git complains "fatal: core.bare and core.worktree
314         # do not make sense"
315         git config core.bare false
316         # in core.worktree, keep a relative reference to the base directory
317         git config core.worktree $(cd $GIT_DIR && GIT_WORK_TREE="$VCSH_BASE" git rev-parse --show-cdup)
318         [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && git config core.excludesfile ".gitignore.d/$VCSH_REPO_NAME"
319         git config vcsh.vcsh         'true'
320         use
321         [ -e "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" ] && git add -f "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME"
322         hook post-upgrade
323 }
324
325 use() {
326         git_dir_exists
327         export VCSH_DIRECTORY="$VCSH_REPO_NAME"
328 }
329
330 which() {
331         for VCSH_REPO_NAME in $(list); do
332                 for VCSH_FILE in $(get_files); do
333                         echo $VCSH_FILE | grep -q "$VCSH_COMMAND_PARAMETER" && echo "$VCSH_REPO_NAME: $VCSH_FILE"
334                 done
335         done | sort -u
336 }
337
338 write_gitignore() {
339         # Don't do anything if the user does not want to write gitignore
340         if [ "x$VCSH_GITIGNORE" = 'xnone' ]; then
341                 info "Not writing gitignore as '\$VCSH_GITIGNORE' is set to 'none'"
342                 exit
343         fi
344
345         use
346         cd "$VCSH_BASE" || fatal "could not enter '$VCSH_BASE'" 11
347         gitignores=$(for file in $(git ls-files); do
348                 while true; do
349                         echo $file; new="${file%/*}"
350                         [ "$file" = "$new" ] && break
351                         file="$new"
352                 done;
353         done | sort -u)
354         tempfile=$(mktemp) || fatal "could not create tempfile" 51
355         echo '*' > "$tempfile" || fatal "could not write to '$tempfile'" 57
356         for gitignore in $gitignores; do
357                 echo "$gitignore" | sed 's@^@!/@' >> "$tempfile" || fatal "could not write to '$tempfile'" 57
358                 if [ "x$VCSH_GITIGNORE" = 'xrecursive' ] && [ -d "$gitignore" ]; then
359                         { echo "$gitignore/*" | sed 's@^@!/@' >> "$tempfile" || fatal "could not write to '$tempfile'" 57; }
360                 fi
361         done
362         if diff -N "$tempfile" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" > /dev/null; then
363                 rm -f "$tempfile" || error "could not delete '$tempfile'"
364                 exit
365         fi
366         if [ -e "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" ]; then
367                 info "'$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME' differs from new data, moving it to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak'"
368                 mv -f "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak" ||
369                         fatal "could not move '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME' to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak'" 53
370         fi
371         mv -f "$tempfile" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" ||
372                 fatal "could not move '$tempfile' to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME'" 53
373 }
374
375 if [ ! "x$VCSH_GITIGNORE" = 'xexact' ] && [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && [ ! "x$VCSH_GITIGNORE" = 'xrecursive' ]; then
376         fatal "'\$VCSH_GITIGNORE' must equal 'exact', 'none', or 'recursive'" 1
377 fi
378
379 if [ "$1" = 'clone' ]; then
380         [ -z "$2" ] && fatal "$1: please specify a remote" 1
381         export VCSH_COMMAND="$1"
382         GIT_REMOTE="$2"
383         [ -n "$3" ] && VCSH_REPO_NAME="$3" || VCSH_REPO_NAME=$(basename "$GIT_REMOTE" .git)
384         export VCSH_REPO_NAME
385         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
386 elif [ "$1" = 'version' ]; then
387         echo "$SELF $VERSION"
388         exit
389 elif [ "$1" = 'which' ]; then
390         [ -z "$2" ] && fatal "$1: please specify a filename" 1
391         [ -n "$3" ] && fatal "$1: too many parameters" 1
392         export VCSH_COMMAND="$1"
393         export VCSH_COMMAND_PARAMETER="$2"
394 elif [ "$1" = 'delete' ]           ||
395      [ "$1" = 'enter' ]            ||
396      [ "$1" = 'init' ]             ||
397      [ "$1" = 'list-tracked-by' ]  ||
398      [ "$1" = 'rename' ]           ||
399      [ "$1" = 'run' ]              ||
400      [ "$1" = 'upgrade' ]          ||
401      [ "$1" = 'write-gitignore' ]; then
402         [ -z $2 ]                      && fatal "$1: please specify repository to work on" 1
403         [ "$1" = 'rename' -a -z "$3" ] && fatal "$1: please specify a target name" 1
404         [ "$1" = 'run'    -a -z "$3" ] && fatal "$1: please specify a command" 1
405         export VCSH_COMMAND="$1"
406         export VCSH_REPO_NAME="$2"
407         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
408         [ "$VCSH_COMMAND" = 'rename' ] && { export VCSH_REPO_NAME_NEW="$3";
409                                             export GIT_DIR_NEW="$VCSH_REPO_D/$VCSH_REPO_NAME_NEW.git"; }
410         [ "$VCSH_COMMAND" = 'run' ]    && shift 2
411 elif [ "$1" = 'commit' ] ||
412      [ "$1" = 'list' ] ||
413      [ "$1" = 'list-tracked' ] ||
414      [ "$1" = 'pull' ] ||
415      [ "$1" = 'push' ]; then
416         export VCSH_COMMAND="$1"
417 elif [ "$1" = 'status' ]; then
418         export VCSH_COMMAND="$1"
419         export VCSH_REPO_NAME="$2"
420 elif [ -n "$2" ]; then
421         export VCSH_COMMAND='run'
422         export VCSH_REPO_NAME="$1"
423         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
424         [ -d $GIT_DIR ] || { help; exit 1; }
425         shift 1
426         set -- "git" "$@"
427 elif [ -n "$1" ]; then
428         export VCSH_COMMAND='enter'
429         export VCSH_REPO_NAME="$1"
430         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
431         [ -d $GIT_DIR ] || { help; exit 1; }
432 else
433         # $1 is empty, or 'help'
434         help && exit
435 fi
436
437 # Did we receive a directory instead of a name?
438 # Mangle the input to fit normal operation.
439 if echo $VCSH_REPO_NAME | grep -q '/'; then
440         export GIT_DIR=$VCSH_REPO_NAME
441         export VCSH_REPO_NAME=$(basename "$VCSH_REPO_NAME" .git)
442 fi
443
444 check_dir() {
445         check_directory="$1"
446         if [ ! -d "$check_directory" ]; then
447                 if [ -e "$check_directory" ]; then
448                         fatal "'$check_directory' exists but is not a directory" 13
449                 else
450                         verbose "attempting to create '$check_directory'"
451                         mkdir -p "$check_directory" || fatal "could not create '$check_directory'" 50
452                 fi
453         fi
454 }
455
456 check_dir "$VCSH_REPO_D"
457 [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && check_dir "$VCSH_BASE/.gitignore.d"
458
459 verbose "$VCSH_COMMAND begin"
460 export VCSH_COMMAND=$(echo $VCSH_COMMAND | sed 's/-/_/g')
461 hook pre-command
462 $VCSH_COMMAND "$@"
463 hook post-command
464 verbose "$VCSH_COMMAND end, exiting"