#!/bin/sh # This program is licensed under the GNU GPL version 2 or later. # (c) Richard "RichiH" Hartmann , 2011-2015 # For details, see LICENSE. To submit patches, you have to agree to # license your code under the GNU GPL version 2 or later. # While the following is not legally binding, the author would like to # explain the choice of GPLv2+ over GPLv3+. # The author prefers GPLv3+ over GPLv2+ but feels it's better to maintain # full compatibility's with Git. In case Git ever changes its licensing terms, # which is admittedly extremely unlikely to the point of being impossible, # this software will most likely follow suit. # This should always be the first line of code to facilitate debugging [ -n "$VCSH_DEBUG" ] && set -vx # If '.git-HEAD' is appended to the version, you are seeing an unreleased # version of vcsh; the master branch is supposed to be clean at all times # so you can most likely just use it nonetheless VERSION='1.20141026' SELF=$(basename $0) fatal() { echo "$SELF: fatal: $1" >&2 [ -z $2 ] && exit 1 exit $2 } # We need to run getops as soon as possible so we catch -d and other # options that will modify our behaviour. # Commands are handled at the end of this script. while getopts "c:dv" flag; do if [ x"$1" = x'-d' ] || [ x"$1" = x'--debug' ]; then set -vx VCSH_DEBUG=1 echo "debug mode on" echo "$SELF $VERSION" elif [ x"$1" = x'-v' ]; then VCSH_VERBOSE=1 echo "verbose mode on" echo "$SELF $VERSION" elif [ x"$1" = x'-c' ]; then VCSH_OPTION_CONFIG=$OPTARG fi shift 1 done source_all() { # Source file even if it's in $PWD and does not have any slashes in it case $1 in */*) . "$1";; *) . "$PWD/$1";; esac; } # Read configuration and set defaults if anything's not set [ -n "$VCSH_DEBUG" ] && set -vx : ${XDG_CONFIG_HOME:="$HOME/.config"} # Read configuration files if there are any [ -r "/etc/vcsh/config" ] && . "/etc/vcsh/config" [ -r "$XDG_CONFIG_HOME/vcsh/config" ] && . "$XDG_CONFIG_HOME/vcsh/config" if [ -n "$VCSH_OPTION_CONFIG" ]; then # Source $VCSH_OPTION_CONFIG if it can be read and is in $PWD of $PATH if [ -r "$VCSH_OPTION_CONFIG" ]; then source_all "$VCSH_OPTION_CONFIG" else fatal "Can not read configuration file '$VCSH_OPTION_CONFIG'" 1 fi fi [ -n "$VCSH_DEBUG" ] && set -vx # Read defaults : ${VCSH_REPO_D:="$XDG_CONFIG_HOME/vcsh/repo.d"} : ${VCSH_HOOK_D:="$XDG_CONFIG_HOME/vcsh/hooks-enabled"} : ${VCSH_OVERLAY_D:="$XDG_CONFIG_HOME/vcsh/overlays-enabled"} : ${VCSH_BASE:="$HOME"} : ${VCSH_GITIGNORE:=exact} : ${VCSH_GITATTRIBUTES:=none} : ${VCSH_WORKTREE:=absolute} if [ ! "x$VCSH_GITIGNORE" = 'xexact' ] && [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && [ ! "x$VCSH_GITIGNORE" = 'xrecursive' ]; then fatal "'\$VCSH_GITIGNORE' must equal 'exact', 'none', or 'recursive'" 1 fi if [ ! "x$VCSH_WORKTREE" = 'xabsolute' ] && [ ! "x$VCSH_WORKTREE" = 'xrelative' ]; then fatal "'\$VCSH_WORKTREE' must equal 'absolute', or 'relative'" 1 fi help() { echo "usage: $SELF options: -c Source file -d Enable debug mode -v Enable verbose mode commands: clone [-b ] \\ \\ [] Clone from an existing repository commit Commit in all repositories delete Delete an existing repository enter Enter repository; spawn new instance of \$SHELL help Display this help text init Initialize a new repository list List all repositories list-tracked \\ [] List all files tracked all or one repositories list-untracked \\ [<-r>] [] List all files not tracked by all or one repositories pull Pull from all vcsh remotes push Push to vcsh remotes rename \\ Rename repository run \\ Use this repository status \\ [--terse] [] Show statuses of all/one vcsh repositories upgrade Upgrade repository to currently recommended settings version Print version information which Find substring in name of any tracked file write-gitignore \\ Write .gitignore.d/ via git ls-files Shortcut to run git commands directly Shortcut to enter repository" >&2 } debug() { [ -n "$VCSH_DEBUG" ] && echo "$SELF: debug: $@" } verbose() { if [ -n "$VCSH_DEBUG" ] || [ -n "$VCSH_VERBOSE" ]; then echo "$SELF: verbose: $@"; fi } error() { echo "$SELF: error: $1" >&2 } info() { echo "$SELF: info: $1" } clone() { hook pre-clone init git remote add origin "$GIT_REMOTE" git checkout -b "$VCSH_BRANCH" || return $? git config branch."$VCSH_BRANCH".remote origin git config branch."$VCSH_BRANCH".merge refs/heads/"$VCSH_BRANCH" if [ $(git ls-remote origin "$VCSH_BRANCH" 2> /dev/null | wc -l ) -lt 1 ]; then info "remote is empty, not merging anything. You should add files to your new repository." exit fi git fetch origin "$VCSH_BRANCH" hook pre-merge git ls-tree -r --name-only origin/"$VCSH_BRANCH" | (while read object; do [ -e "$object" ] && error "'$object' exists." && VCSH_CONFLICT=1 done [ x"$VCSH_CONFLICT" = x'1' ]) && fatal "will stop after fetching and not try to merge! Once this situation has been resolved, run 'vcsh $VCSH_REPO_NAME pull' to finish cloning." 17 git merge origin/"$VCSH_BRANCH" hook post-merge hook post-clone retire hook post-clone-retired } commit() { hook pre-commit for VCSH_REPO_NAME in $(list); do echo "$VCSH_REPO_NAME: " GIT_DIR=$VCSH_REPO_D/$VCSH_REPO_NAME.git; export GIT_DIR use git commit --untracked-files=no --quiet VCSH_COMMAND_RETURN_CODE=$? echo done hook post-commit } delete() { cd "$VCSH_BASE" || fatal "could not enter '$VCSH_BASE'" 11 use info "This operation WILL DESTROY DATA!" files=$(git ls-files) echo "These files will be deleted: $files AGAIN, THIS WILL DELETE YOUR DATA! To continue, type 'Yes, do as I say'" read answer [ "x$answer" = 'xYes, do as I say' ] || exit 16 for file in $files; do rm -f $file || info "could not delete '$file', continuing with deletion" done rm -rf "$GIT_DIR" || error "could not delete '$GIT_DIR'" } enter() { hook pre-enter use $SHELL hook post-enter } git_dir_exists() { [ -d "$GIT_DIR" ] || fatal "no repository found for '$VCSH_REPO_NAME'" 12 } hook() { for hook in "$VCSH_HOOK_D/$1"* "$VCSH_HOOK_D/$VCSH_REPO_NAME.$1"*; do [ -x "$hook" ] || continue verbose "executing '$hook'" "$hook" done } init() { hook pre-init [ ! -e "$GIT_DIR" ] || fatal "'$GIT_DIR' exists" 10 mkdir -p "$VCSH_BASE" || fatal "could not create '$VCSH_BASE'" 50 cd "$VCSH_BASE" || fatal "could not enter '$VCSH_BASE'" 11 git init --shared=0600 upgrade hook post-init } list() { for repo in "$VCSH_REPO_D"/*.git; do [ -d "$repo" ] && [ -r "$repo" ] && echo "$(basename "$repo" .git)" done } get_files() { GIT_DIR=$VCSH_REPO_D/$VCSH_REPO_NAME.git; export GIT_DIR git ls-files } list_tracked() { VCSH_REPO_NAME=$2; export VCSH_REPO_NAME if [ -n "$VCSH_REPO_NAME" ]; then get_files | list_tracked_helper else for VCSH_REPO_NAME in $(list); do get_files done | list_tracked_helper fi } list_tracked_helper() { sed "s,^,$(printf '%s\n' "$VCSH_BASE/" | sed 's/[,\&]/\\&/g')," | sort -u } list_tracked_by() { list_tracked $2 } list_untracked() { command -v 'comm' >/dev/null 2>&1 || fatal "Could not find 'comm'" temp_file_others=$(mktemp "${TMPDIR:-/tmp}/tmp.XXXXXXXXXX") || fatal 'Could not create temp file' temp_file_untracked=$(mktemp "${TMPDIR:-/tmp}/tmp.XXXXXXXXXX") || fatal 'Could not create temp file' temp_file_untracked_copy=$(mktemp "${TMPDIR:-/tmp}/tmp.XXXXXXXXXX") || fatal 'Could not create temp file' # Hack in support for `vcsh list-untracked -r`... directory_opt="--directory" shift 1 while getopts "r" flag; do if [ x"$1" = x'-r' ]; then unset directory_opt fi shift 1 done # ...and parse for a potential parameter afterwards. As we shifted things out of $* in during getops, we need to look at $1 VCSH_REPO_NAME=$1; export VCSH_REPO_NAME if [ -n "$VCSH_REPO_NAME" ]; then list_untracked_helper $VCSH_REPO_NAME else for VCSH_REPO_NAME in $(list); do list_untracked_helper $VCSH_REPO_NAME done fi cat $temp_file_untracked unset directory_opt directory_component rm -f $temp_file_others $temp_file_untracked $temp_file_untracked_copy || fatal 'Could not delete temp files' } list_untracked_helper() { export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git" git ls-files --others "$directory_opt" | ( while read line; do echo "$line" directory_component=${line%%/*} [ -d "$directory_component" ] && printf '%s/\n' "$directory_component" done ) | sort -u > $temp_file_others if [ -z "$ran_once" ]; then ran_once=1 cp $temp_file_others $temp_file_untracked || fatal 'Could not copy temp file' fi cp $temp_file_untracked $temp_file_untracked_copy || fatal 'Could not copy temp file' comm -12 $temp_file_others $temp_file_untracked_copy > $temp_file_untracked } pull() { hook pre-pull for VCSH_REPO_NAME in $(list); do printf '%s: ' "$VCSH_REPO_NAME" GIT_DIR=$VCSH_REPO_D/$VCSH_REPO_NAME.git; export GIT_DIR use git pull VCSH_COMMAND_RETURN_CODE=$? echo done hook post-pull } push() { hook pre-push for VCSH_REPO_NAME in $(list); do printf '%s: ' "$VCSH_REPO_NAME" GIT_DIR=$VCSH_REPO_D/$VCSH_REPO_NAME.git; export GIT_DIR use git push VCSH_COMMAND_RETURN_CODE=$? echo done hook post-push } retire() { unset VCSH_DIRECTORY } rename() { git_dir_exists [ -d "$GIT_DIR_NEW" ] && fatal "'$GIT_DIR_NEW' exists" 54 mv -f "$GIT_DIR" "$GIT_DIR_NEW" || fatal "Could not mv '$GIT_DIR' '$GIT_DIR_NEW'" 52 # Now that the repository has been renamed, we need to fix up its configuration # Overwrite old name.. GIT_DIR=$GIT_DIR_NEW VCSH_REPO_NAME=$VCSH_REPO_NAME_NEW # ..and clobber all old configuration upgrade } run() { hook pre-run use "$@" VCSH_COMMAND_RETURN_CODE=$? hook post-run } status() { if [ -t 1 ]; then COLORING="-c color.status=always" fi if [ -n "$VCSH_REPO_NAME" ]; then status_helper $VCSH_REPO_NAME else for VCSH_REPO_NAME in $(list); do STATUS=$(status_helper $VCSH_REPO_NAME "$COLORING") [ -n "$STATUS" -o -z "$VCSH_STATUS_TERSE" ] && echo "$VCSH_REPO_NAME:" [ -n "$STATUS" ] && echo "$STATUS" [ -z "$VCSH_STATUS_TERSE" ] && echo done fi } status_helper() { GIT_DIR=$VCSH_REPO_D/$1.git; export GIT_DIR VCSH_GIT_OPTIONS=$2 use remote_tracking_branch=$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2> /dev/null) && { commits_behind=$(git log ..${remote_tracking_branch} --oneline | wc -l) commits_ahead=$(git log ${remote_tracking_branch}.. --oneline | wc -l) [ ${commits_behind} -ne 0 ] && echo "Behind $remote_tracking_branch by $commits_behind commits" [ ${commits_ahead} -ne 0 ] && echo "Ahead of $remote_tracking_branch by $commits_ahead commits" } git ${VCSH_GIT_OPTIONS} status --short --untracked-files='no' VCSH_COMMAND_RETURN_CODE=$? } upgrade() { hook pre-upgrade # fake-bare repositories are not bare, actually. Set this to false # because otherwise Git complains "fatal: core.bare and core.worktree # do not make sense" git config core.bare false # core.worktree may be absolute or relative to $GIT_DIR, depending on # user preference if [ ! "x$VCSH_WORKTREE" = 'xabsolute' ]; then git config core.worktree "$(cd "$GIT_DIR" && GIT_WORK_TREE=$VCSH_BASE git rev-parse --show-cdup)" elif [ ! "x$VCSH_WORKTREE" = 'xrelative' ]; then git config core.worktree "$VCSH_BASE" fi [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && git config core.excludesfile ".gitignore.d/$VCSH_REPO_NAME" [ ! "x$VCSH_GITATTRIBUTES" = 'xnone' ] && git config core.attributesfile ".gitattributes.d/$VCSH_REPO_NAME" git config vcsh.vcsh 'true' use [ -e "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" ] && git add -f "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" [ -e "$VCSH_BASE/.gitattributes.d/$VCSH_REPO_NAME" ] && git add -f "$VCSH_BASE/.gitattributes.d/$VCSH_REPO_NAME" hook post-upgrade } use() { git_dir_exists VCSH_DIRECTORY=$VCSH_REPO_NAME; export VCSH_DIRECTORY } which() { output=$(for VCSH_REPO_NAME in $(list); do for VCSH_FILE in $(get_files); do echo "$VCSH_FILE" | grep -q "$VCSH_COMMAND_PARAMETER" && echo "$VCSH_REPO_NAME: $VCSH_FILE" done done | sort -u) if [ -z "$output" ]; then fatal "'$VCSH_COMMAND_PARAMETER' does not exist" 1 else echo "$output" fi } write_gitignore() { # Don't do anything if the user does not want to write gitignore if [ "x$VCSH_GITIGNORE" = 'xnone' ]; then info "Not writing gitignore as '\$VCSH_GITIGNORE' is set to 'none'" exit fi use cd "$VCSH_BASE" || fatal "could not enter '$VCSH_BASE'" 11 OLDIFS=$IFS IFS=$(printf '\n\t') gitignores=$(for file in $(git ls-files); do while true; do echo "$file"; new=${file%/*} [ x"$file" = x"$new" ] && break file=$new done; done | sort -u) # Contrary to GNU mktemp, mktemp on BSD/OSX requires a template for temp files # Using a template makes GNU mktemp default to $PWD and not #TMPDIR for tempfile location # To make every OS happy, set full path explicitly tempfile=$(mktemp "${TMPDIR:-/tmp}/tmp.XXXXXXXXXX") || fatal "could not create tempfile: '${tempfile}'" 51 echo '*' > "$tempfile" || fatal "could not write to '$tempfile'" 57 for gitignore in $gitignores; do echo "$gitignore" | sed 's@^@!/@' >> "$tempfile" || fatal "could not write to '$tempfile'" 57 if [ "x$VCSH_GITIGNORE" = 'xrecursive' ] && [ -d "$gitignore" ]; then { echo "$gitignore/*" | sed 's@^@!/@' >> "$tempfile" || fatal "could not write to '$tempfile'" 57; } fi done IFS=$OLDIFS if diff -N "$tempfile" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" > /dev/null; then rm -f "$tempfile" || error "could not delete '$tempfile'" exit fi if [ -e "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" ]; then info "'$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME' differs from new data, moving it to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak'" mv -f "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak" || fatal "could not move '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME' to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak'" 53 fi mv -f "$tempfile" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" || fatal "could not move '$tempfile' to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME'" 53 } debug $(git version) if [ ! "x$VCSH_GITIGNORE" = 'xexact' ] && [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && [ ! "x$VCSH_GITIGNORE" = 'xrecursive' ]; then fatal "'\$VCSH_GITIGNORE' must equal 'exact', 'none', or 'recursive'" 1 fi VCSH_COMMAND=$1; export VCSH_COMMAND case $VCSH_COMMAND in clon|clo|cl) VCSH_COMMAND=clone;; commi|comm|com|co) VCSH_COMMAND=commit;; delet|dele|del|de) VCSH_COMMAND=delete;; ente|ent|en) VCSH_COMMAND=enter;; hel|he) VCSH_COMMAND=help;; ini|in) VCSH_COMMAND=init;; pul) VCSH_COMMAND=pull;; pus) VCSH_COMMAND=push;; renam|rena|ren|re) VCSH_COMMAND=rename;; ru) VCSH_COMMAND=run;; statu|stat|sta|st) VCSH_COMMAND=status;; upgrad|upgra|upgr|up) VCSH_COMMAND=upgrade;; versio|versi|vers|ver|ve) VCSH_COMMAND=version;; which|whi|wh) VCSH_COMMAND=which;; write|writ|wri|wr) VCSH_COMMAND=write-gitignore;; esac if [ x"$VCSH_COMMAND" = x'clone' ]; then VCSH_BRANCH= if [ "$2" = -b ]; then VCSH_BRANCH=$3 shift shift fi [ -z "$2" ] && fatal "$VCSH_COMMAND: please specify a remote" 1 GIT_REMOTE="$2" [ -n "$VCSH_BRANCH" ] || if [ "$3" = -b ]; then VCSH_BRANCH=$4 shift shift fi if [ -n "$3" ]; then VCSH_REPO_NAME=$3 [ -z "$VCSH_BRANCH" ] && [ "$4" = -b ] && VCSH_BRANCH=$5 else VCSH_REPO_NAME=$(basename "${GIT_REMOTE#*:}" .git) fi [ -z "$VCSH_REPO_NAME" ] && fatal "$VCSH_COMMAND: could not determine repository name" 1 export VCSH_REPO_NAME [ -n "$VCSH_BRANCH" ] || VCSH_BRANCH=master GIT_DIR=$VCSH_REPO_D/$VCSH_REPO_NAME.git; export GIT_DIR elif [ "$VCSH_COMMAND" = 'version' ]; then echo "$SELF $VERSION" git version exit elif [ x"$VCSH_COMMAND" = x'which' ]; then [ -z "$2" ] && fatal "$VCSH_COMMAND: please specify a filename" 1 [ -n "$3" ] && fatal "$VCSH_COMMAND: too many parameters" 1 VCSH_COMMAND_PARAMETER=$2; export VCSH_COMMAND_PARAMETER elif [ x"$VCSH_COMMAND" = x'delete' ] || [ x"$VCSH_COMMAND" = x'enter' ] || [ x"$VCSH_COMMAND" = x'init' ] || [ x"$VCSH_COMMAND" = x'list-tracked-by' ] || [ x"$VCSH_COMMAND" = x'rename' ] || [ x"$VCSH_COMMAND" = x'run' ] || [ x"$VCSH_COMMAND" = x'upgrade' ] || [ x"$VCSH_COMMAND" = x'write-gitignore' ]; then [ -z "$2" ] && fatal "$VCSH_COMMAND: please specify repository to work on" 1 [ x"$VCSH_COMMAND" = x'rename' ] && [ -z "$3" ] && fatal "$VCSH_COMMAND: please specify a target name" 1 [ x"$VCSH_COMMAND" = x'run' ] && [ -z "$3" ] && fatal "$VCSH_COMMAND: please specify a command" 1 VCSH_REPO_NAME=$2; export VCSH_REPO_NAME GIT_DIR=$VCSH_REPO_D/$VCSH_REPO_NAME.git; export GIT_DIR [ x"$VCSH_COMMAND" = x'rename' ] && { VCSH_REPO_NAME_NEW=$3; export VCSH_REPO_NAME_NEW; GIT_DIR_NEW=$VCSH_REPO_D/$VCSH_REPO_NAME_NEW.git; export GIT_DIR_NEW; } [ x"$VCSH_COMMAND" = x'run' ] && shift 2 elif [ x"$VCSH_COMMAND" = x'commit' ] || [ x"$VCSH_COMMAND" = x'list' ] || [ x"$VCSH_COMMAND" = x'list-tracked' ] || [ x"$VCSH_COMMAND" = x'list-untracked' ] || [ x"$VCSH_COMMAND" = x'pull' ] || [ x"$VCSH_COMMAND" = x'push' ]; then : elif [ x"$VCSH_COMMAND" = x'status' ]; then if [ x"$2" = x'--terse' ]; then VCSH_STATUS_TERSE=1; export VCSH_STATUS_TERSE shift fi VCSH_REPO_NAME=$2; export VCSH_REPO_NAME elif [ -n "$2" ]; then VCSH_COMMAND='run'; export VCSH_COMMAND VCSH_REPO_NAME=$1; export VCSH_REPO_NAME GIT_DIR=$VCSH_REPO_D/$VCSH_REPO_NAME.git; export GIT_DIR [ -d "$GIT_DIR" ] || { help; exit 1; } shift 1 set -- "git" "$@" elif [ -n "$VCSH_COMMAND" ]; then VCSH_COMMAND='enter'; export VCSH_COMMAND VCSH_REPO_NAME=$1; export VCSH_REPO_NAME GIT_DIR=$VCSH_REPO_D/$VCSH_REPO_NAME.git; export GIT_DIR [ -d "$GIT_DIR" ] || { help; exit 1; } else # $1 is empty, or 'help' help && exit fi # Did we receive a directory instead of a name? # Mangle the input to fit normal operation. if echo "$VCSH_REPO_NAME" | grep -q '/'; then GIT_DIR=$VCSH_REPO_NAME; export GIT_DIR VCSH_REPO_NAME=$(basename "$VCSH_REPO_NAME" .git); export VCSH_REPO_NAME fi check_dir() { check_directory="$1" if [ ! -d "$check_directory" ]; then if [ -e "$check_directory" ]; then fatal "'$check_directory' exists but is not a directory" 13 else verbose "attempting to create '$check_directory'" mkdir -p "$check_directory" || fatal "could not create '$check_directory'" 50 fi fi } check_dir "$VCSH_REPO_D" [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && check_dir "$VCSH_BASE/.gitignore.d" [ ! "x$VCSH_GITATTRIBUTES" = 'xnone' ] && check_dir "$VCSH_BASE/.gitattributes.d" verbose "$VCSH_COMMAND begin" VCSH_COMMAND=$(echo "$VCSH_COMMAND" | sed 's/-/_/g'); export VCSH_COMMAND # Source repo-specific configuration file [ -r "$XDG_CONFIG_HOME/vcsh/config.d/$VCSH_REPO_NAME" ] && . "$XDG_CONFIG_HOME/vcsh/config.d/$VCSH_REPO_NAME" # source overlay functions for overlay in "$VCSH_OVERLAY_D/$VCSH_COMMAND"* "$VCSH_OVERLAY_D/$VCSH_REPO_NAME.$VCSH_COMMAND"*; do [ -r "$overlay" ] || continue info "sourcing '$overlay'" . "$overlay" done hook pre-command $VCSH_COMMAND "$@" hook post-command verbose "$VCSH_COMMAND end, exiting" exit $VCSH_COMMAND_RETURN_CODE