faster mgit which

This commit is contained in:
Cosmin Apreutesei
2021-11-26 20:13:54 +02:00
parent 1edbf2fb55
commit 4ae7691c15
2 changed files with 726 additions and 2 deletions
+2 -2
View File
@@ -120,8 +120,8 @@ tracked_files() {
which_tracks() {
[ "$1" ] || die "Filename expected."
list_tracked | while IFS=" " read repo file; do
[ "$file" = "$1" ] && echo "$repo"
list_cloned | while IFS=" " read repo; do
[ "$(GIT_DIR=".mgit/$repo/.git" git ls-files "$1")" = "$1" ] && echo "$repo"
done
}
+724
View File
@@ -0,0 +1,724 @@
#!/bin/bash
shopt -s nullglob
IFS=$'\n\b'
# die hard, see https://github.com/capr/die
say() { echo "$@" >&2; }
die() { echo -n "ABORT: " >&2; echo "$@" >&2; exit 1; }
debug() { [ "$DEBUG" ] && echo "$@" >&2; }
run() { debug -n "EXEC: $@ "; "$@"; local ret=$?; debug "[$ret]"; return $ret; }
must() { debug -n "MUST: $@ "; "$@"; local ret=$?; debug "[$ret]"; [ $ret = 0 ] || die "$@ [$ret]"; }
dry() { if [ "$DRY" ]; then [ "$VERBOSE" ] && say "$@"; else "$@"; fi; }
usage() {
say
say " multigit 4.4b - git wrapper for working with overlaid repos."
say " Cosmin Apreutesei | public domain | https://github.com/capr/multigit"
say
say " USAGE: mgit [OPTIONS...] COMMAND ..."
say
say " ls list cloned repos"
say " ls-all list all known repos"
say " ls-uncloned list all known but not cloned repos"
say
say " ls-modified|st[atus] list modified files across all repos"
say " ls-unpushed list repos that are ahead of origin"
say " ls-untracked list files untracked by any repo"
say " ls-double-tracked list files tracked by multiple repos"
say " ls-tracked list files and which repos are tracking them"
say " which FILENAME list which repo(s) are tracking a file"
say
say " init REPO create a local repo"
say " clone [REMOTE/]REPO|URL[=VERSION] ... clone one ore more repos"
say " clone-all clone all known uncloned repos"
say " clone-release REL|RELFILE clone/checkout all repos from a release (file)"
say " remove REPO ... remove repos from disk (!)"
say " convert [NAME] convert current git repo to mgit"
say
say " baseurl [REMOTE [URL|-]] get/set/delete the baseurl of a remote"
say " origin [REPO [REMOTE|URL|-]] get/set/delete the known origin of a repo"
say
say " [-] REPO1,... start a shell for using git on a repo"
say " [-] REPO1,...|--all COMMAND ... execute any git command on a repo"
say " [-] REPO1,...|--all exec ... execute a shell command in a repo context"
say " [-] REPO1,...|--all ver[sion] [tag] show repo version or tag (as enum or list)"
say " [-] REPO1,...|--all make-symlinks make symbolic links in .mgit/REPO"
say " [-] REPO1,...|--all make-hardlinks make hard links in .mgit/REPO"
say
say " release [REL] show a release or list releases"
say " release REL update [tag] create/update a release based on HEADs"
say " release REL clone clone/checkout all repos from a release"
say " release REL remove remove a release file"
say
say " bash drop to bash (even on Windows)"
say " [help|--help] show this screen"
say
say " OPTIONS:"
say
say " -v verbose"
say " --debug print commands"
say " --dry don't actually remove stuff"
say " --yes choose yes when asked to remove stuff"
say " -SS reuse SSH connections (Linux only)"
say " -P N number of parallel processes for clone (1)"
say
# append any plugin help files
for f in .mgit/*.help; do
cat "$f"
done
exit
}
check_root() { [ -d .mgit ] || die "'.mgit' dir not found."; }
list_known() {
check_root
(cd .mgit && \
for f in *.origin; do
echo "${f%.origin}"
done)
}
list_cloned() {
check_root
(cd .mgit && \
for f in *; do
[ -d "$f/.git" ] && echo "$f"
done)
}
list_uncloned() {
check_root
(cd .mgit && \
for f in *.origin; do
f="${f%.origin}"
[ ! -d "$f/.git" ] && echo $f
done)
}
list_modified() {
check_root
"$0" --all "$@" status -s
}
list_unpushed() {
check_root
for repo in `list_cloned`; do
[ "$(GIT_DIR=".mgit/$repo/.git" \
git rev-list HEAD...origin/master --count 2>/dev/null)" \
!= "0" ] && echo "$repo"
done
}
tracked_files() {
check_root
([ -d .git ] && git ls-files
for repo in `list_cloned`; do
GIT_DIR=".mgit/$repo/.git" git ls-files
done) | sort | uniq $1
}
which_tracks() {
[ "$1" ] || die "Filename expected."
list_cloned | while IFS=" " read repo; do
# xargs -i -P "$MAXPROC" --
[ "$(GIT_DIR=".mgit/$repo/.git" git ls-files "$1")" = "$1" ] && echo "$repo"
# [ "$file" = "$1" ] && echo "$repo"
done
}
existing_files() {
find * -type f | grep -v "^$1$" | sort
}
list_tracked() {
check_root
local MGIT_DIR="$PWD"
for repo in `list_cloned`; do
(cd "$PWD0"; GIT_DIR="$MGIT_DIR/.mgit/$repo/.git" git ls-files) | while read f; do
printf "%-16s %s\n" "$repo" "$f"
done
done
}
list_sub() { # list_sub A B removes from list A the lines found in list B.
# NOTE: using a temp file because bash on Windows can't do <(tracked_files)
"$2" | sort > $$.tmp
if [ "$OSTYPE" != "msys" ]; then
# NOTE: not using grep because it's buggy on OSX (doesn't match all).
"$1" $$.tmp | comm -23 - $$.tmp
else
# NOTE: using grep because Git for Windows doesn't have comm,
# and because if MSYS is in PATH, comm from MSYS can't be used with
# Git's bash (it crashes).
"$1" $$.tmp | grep -v -F -x -f $$.tmp
fi
rm $$.tmp
}
list_untracked() {
check_root
list_sub existing_files tracked_files
}
list_double_tracked() {
check_root
# NOTE: using a temp file because bash on Windows can't do <(tracked_files -d)
tracked_files -d > $$.tmp
[ "$(cat $$.tmp)" ] && \
PWD0="$PWD" list_tracked | grep -F -w -f $$.tmp
rm $$.tmp
}
clone_all() {
for repo in `list_uncloned`; do
"$0" clone "$repo"
done
}
check_repo_name() {
[ "$1" ] || die "Repo name expected."
local name="$1"
name="${name//[^\.\-_0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ]/}"
[ "$name" = "$1" ] || die "Invalid name: '$1'. Only letters, numbers, '.', '-' and '_' allowed."
name="${name//^[\.\-]/}"
[ "$name" = "$1" ] || die "Invalid name: '$1'. Must not start with '.' or '-'."
}
init() {
check_repo_name "$1"
local name="$1"
[ -d ".mgit/$name" ] && die "Already cloned: '$name'."
must mkdir -p ".mgit/$name"
export GIT_DIR=".mgit/$name/.git"
must git init $GITOPT $MULTIGIT_INIT_OPTS >&2
must git config core.worktree ../../..
must git config core.excludesfile ".mgit/$name.exclude"
must make_exclude_file "$name"
}
make_exclude_file() {
[ "$no_exclude_file" ] && return
[ -f ".mgit/$1.exclude" ] && return
# make a default "exclude-all" exclude file
echo '*' > ".mgit/$1.exclude"
}
clone_one() {
local arg="$1"
local name
local origin
local rorigin
local url
local ver
arg="${arg//[[:blank:]]/}" # spaces not allowed
[ "$arg" = "$1" ] || die "$name is an invalid name"
# extract `=version` if any
ver="${arg##*=}"
[ "$ver" = "$arg" ] && ver=""
arg="${arg%=*}"
# check if the arg is a full url or just `[origin/]name`
if [ "${arg#*:}" != "$arg" ]; then
url="$arg"
origin="$arg"
name="${url##*/}"
name="${name%.git}"
else
name="${arg##*/}"
origin="${arg%/*}"
[ "$origin" = "$arg" ] && origin=""
fi
# check that arg is not `/` or `origin/`
[ "$name" ] || die "$name is an invalid name"
export GIT_DIR=".mgit/$name/.git"
# if if repo is already cloned do a checkout instead.
[ -d ".mgit/$name" ] && {
local ver0="$(git_ver_for "$name")"
[ "$ver" -a "$ver" = "$ver0" ] && {
[ "$VERBOSE" ] && printf "SKIP: %-16s already at %s\n" "$name" "$ver"
exit 0
}
printf "PULL: %-16s %-7s %s\n" "$name" "$ver" "(was: $ver0)" >&2
must git fetch $GITOPT $MULTIGIT_FETCH_OPTS >&2
if [ "$ver" ]; then
must git -c advice.detachedHead=false checkout $GITOPT "$ver" >&2
else
must git checkout $GITOPT -B master origin/master >&2
fi
exit 0
}
# check for a registered origin
[ -f ".mgit/$name.origin" ] && \
rorigin=$(cat .mgit/$name.origin)
# decide the origin
if [ "$origin" ]; then
[ "$rorigin" -a "$origin" != "$rorigin" ] && \
say "NOTE: $name using different origin: '$origin' (was '$rorigin')."
else
origin="$rorigin"
[ "$origin" ] || die "$name has no origin file"
fi
# find the origin url
if [ ! "$url" ]; then
if [ -f ".mgit/$origin.baseurl" ]; then
local baseurl=$(cat .mgit/$origin.baseurl)
url="$baseurl$name"
else
# assume the origin on file is a full url: check if it is
if [ "${origin#*:}" != "$origin" ]; then
url="$origin"
else
say "ABORT: $name has unknown origin: '$origin'."
say "HINT: To register '$origin' to be used as an origin, type, eg.:"
say "HINT: "$(basename "$0")" baseurl $origin https://github.com/$origin/"
exit 1
fi
fi
fi
# finally, clone the repo
(
printf "CLONE: %-16s %s\n" "$name" "$ver" >&2
no_exclude_file=1 must init "$name"
must git remote add origin "$url"
must git fetch $GITOPT $MULTIGIT_FETCH_OPTS >&2
) || {
# cleanup on failed fetch because git doesn't.
rm -rf ".mgit/$name/"
rm -f ".mgit/$name.exclude"
die "$name clone failed"
}
if run git rev-parse $GITOPT --verify origin/master >/dev/null; then
run git branch $GITOPT --track master origin/master >&2
must git -c advice.detachedHead=false checkout $GITOPT $ver >&2
else # new repo
must git checkout $GITOPT -b master
git config branch.master.remote origin
git config branch.master.merge refs/heads/master
fi
# make an exclude file if one wasn't checked out already.
must make_exclude_file "$name"
# (re)register the repo's origin.
if [ "$origin" != "$rorigin" ]; then
if [ "$rorigin" ]; then
say "NOTE: $name has new origin: '$origin' (was: '$rorigin')"
else
[ "$VERBOSE" ] && {
say "NOTE: $name has origin: '$origin'"
say " (url: $url)"
}
fi
must mkdir -p .mgit
echo "$origin" > ".mgit/$name.origin" \
|| die "Could not create origin file .mgit/$name.origin".
fi
}
clone_from_stdin() {
xargs -i -P "$MAXPROC" -- "$0" clone "{}"
}
clone() {
[ "$1" ] || die "Repo name expected."
if [ "$1" = "-" ]; then
clone_from_stdin
elif [ $# = 1 ]; then
clone_one "$1"
else
while [ $# != 0 ]; do
printf "%s\n" "$1"
shift
done | clone_from_stdin
fi
}
remove_one() {
[ -d ".mgit/$1/" ] || die "$1 is not a cloned repo"
# don't remove from a subshell
[ "$MULTIGIT_REPO" = "$1" ] && die "Refusing to remove '$1' from a subshell."
# get tracked files for this repo
files="$(GIT_DIR=".mgit/$1/.git" git ls-files)" || {
say "ABORT: Could not get the list of files for '$1'."
say "HINT: If you know that there are no checked out files,"
say "HINT: feel free to \`rm -rf .mgit/$1/ .mgit/$1.exclude\`."
exit 1
}
# ask for confirmation if there are files to delete
[ "$files" -a -z "$YES" ] && {
local n=$(echo "$files" | wc -l)
say "Remove ALL $((n)) files of '$1'? You can't undo this [yes/N]"
read yes
[ "$yes" = "yes" ] || { say "Canceled."; exit 1; }
}
# remove files
for file in $files; do
dry rm "$file"
done
# remove empty directories
for file in $files; do
echo "$(dirname "$file")"
done | uniq | while read dir; do
[ "$dir" != "." ] && dry /bin/rmdir -p "$dir" 2>/dev/null
done
# remove the git dir
dry rm -rf ".mgit/$1/"
dry rm -f ".mgit/$1.exclude"
say "REMOVED: $1"
}
remove() {
[ "$1" ] || die "Repo name expected."
if [ $# = 1 ]; then
remove_one "$1"
else
while true; do
case "$1" in
--dry) export DRY=1; shift ;;
--yes) export YES=1; shift ;;
*) break ;;
esac
done
while [ $# != 0 ]; do
"$0" remove "$1"
shift
done
fi
}
convert() {
local name="$1"
[ "$name" ] || name="$(basename "$PWD")"
check_repo_name "$name"
[ -d ".mgit/$name" ] && {
[ -d "$PWD0/.git" ] && die "$name already taken"
[ "$VERBOSE" ] && say "SKIP: $name already in multigit"
exit 0
}
[ -d "$PWD0/.git" ] || die "No .git dir in current dir"
must mkdir -p ".mgit/$name"
must mv "$PWD0/.git" ".mgit/$name/.git"
export GIT_DIR=".mgit/$name/.git"
must git config core.worktree ../../..
must git config core.excludesfile ".mgit/$name.exclude"
must make_exclude_file "$name"
say "CONVERTED: $name"
}
baseurl() {
[ "$1" ] || {
for f in .mgit/*.baseurl; do
f="${f#.mgit/}"
f="${f%.baseurl}"
printf "%-20s %s\n" "$f" "$(baseurl "$f")"
done
return
}
local origin="$1"
local url="$2"
# spaces not allowed
origin="${origin//[[:blank:]]/}"
url="${url//[[:blank:]]/}"
[ -z "$1" -o "$1" != "$origin" ] && die "Invalid origin name '$1'."
[ "$2" -a "$2" != "$url" ] && die "Invalid baseurl '$2'."
if [ "X$url" = "X-" ]; then
rm ".mgit/$origin.baseurl"
elif [ "$url" ]; then
[ "${url##*/}" != "" ] && die "A base URL must end with a '/'."
mkdir -p .mgit
echo "$url" > ".mgit/$origin.baseurl"
else
cat ".mgit/$origin.baseurl"
fi
}
origin() {
[ "$1" ] || {
for f in .mgit/*.origin; do
f="${f#.mgit/}"
f="${f%.origin}"
printf "%-20s %s\n" "$f" "$(origin "$f")"
done
return
}
local repo="$1"
local origin="$2"
# spaces not allowed
repo="${repo//[[:blank:]]/}"
origin="${origin//[[:blank:]]/}"
[ -z "$1" -o "$1" != "$repo" ] && die "Invalid repo name '$1'."
[ "$2" -a "$2" != "$origin" ] && die "Invalid origin '$2'."
if [ "X$origin" = "X-" ]; then
rm ".mgit/$repo.origin"
elif [ "$origin" ]; then
mkdir -p .mgit
echo "$origin" > ".mgit/$repo.origin"
else
cat ".mgit/$repo.origin"
fi
}
list_releases() {
for f in .mgit/*.release; do
f="${f#.mgit/}"
f="${f%.release}"
echo "$f"
done
}
release_file() {
local rel="$1"
[ "$rel" ] || die "Release name or file expected."
[ "${rel##*.}" = "release" -a -f "$rel" ] || rel=".mgit/$rel.release"
[ "$2" = "nocheck" -o -f "$rel" ] || die "Release not found: '$1'."
echo "$rel"
}
show_release() {
local f; f="$(release_file "$1")" || exit; cat "$f"
}
update_release() {
local rel; rel="$(release_file "$1" nocheck)" || exit
if [ -f "$rel" ]; then
local s="$(cat "$rel")"
echo "$s" | (IFS=" "; while read repo ver0; do
local name="${repo##*/}"
local ver="$(git_ver_for "$name" $2)"
[ "$ver0" = "*" ] && ver="*" # not touching those
printf "%-20s %s\n" "$repo" "$ver"
[ "$ver" != "$ver0" ] && printf "%-20s %s -> %s\n" "$repo" "$ver0" "$ver" >&2
done) > "$rel"
else
"$0" --all version $2 > "$rel"
fi
}
remove_release() {
local f; f="$(release_file "$1")" || exit; rm "$f"
}
list_release_repos() {
printf "%s\n" "$RELEASE" | (IFS=" "; while read repo ver; do
local name="${repo##*/}"
printf "%s\n" "$name"
done)
}
clone_release() {
local rel; rel="$(release_file "$1")" || exit
local s="$(cat "$rel")" # load it in memory to make sure we have it till the end.
# step 1: remove any repos that are not part of the release.
RELEASE="$s" list_sub list_cloned list_release_repos | while read repo; do
"$0" --yes remove "$repo"
done
# step 2: clone/checkout repos present in the release.
printf "%s\n" "$s" | (IFS=" "; while read repo ver; do
# Version "*" in a release file specifies that the repo is already cloned
# and checked out: this is the repo that contains the release file itself.
# Even if cloned manually beforehand, the repo that contains the release file
# must still be listed in the release file to avoid removing it in step 1.
if [ "$ver" != "*" ]; then
printf "%s\n" "$repo=$ver"
fi
done
) | "$0" clone -
[ $? = 0 ] || die "Failed."
}
release() {
local rel="$1"
local cmd="$2"
rel="${rel//[[:blank:]]/}" # spaces not allowed
[ "$rel" -a "$1" != "$rel" ] && die "Invalid release name '$1'."
[ "$rel" ] || { list_releases; return; }
shift 2
case "$cmd" in
"") show_release "$rel" "$@" ;;
update) update_release "$rel" "$@" ;;
clone) clone_release "$rel" "$@" ;;
remove) remove_release "$rel" "$@" ;;
*) die "Invalid release command '$cmd'."
esac
}
git_shell() {
cd "$PWD0" || return
say "Entering subshell: git commands will affect the repo '$MULTIGIT_REPO'."
say "Type \`exit' to exit subshell."
git status -s
say
if [ "$OSTYPE" = "msys" ]; then
export PROMPT="[$MULTIGIT_REPO] \$P\$G"
"$COMSPEC" /k
else
export PS1="[$MULTIGIT_REPO] \u@\h:\w\$ "
"$SHELL" --norc -i
fi
}
git_ver_for() {
if [ "$2" = "tag" ]; then
GIT_DIR=".mgit/$1/.git" git describe --tags --abbrev=0 2>/dev/null $3
else
GIT_DIR=".mgit/$1/.git" git describe --tags --long --always $3
fi
}
git_ver() {
printf "$MULTIGIT_REPO=$(git_ver_for "$MULTIGIT_REPO" $1) "
}
git_version() {
printf "%-20s %s" "$MULTIGIT_REPO"
git_ver_for "$MULTIGIT_REPO" $1
}
git_remove_links() {
[ "$OSTYPE" = "msys" ] && die "Not for Windows."
([ "$MULTIGIT_REPO" ] && cd ".mgit/$MULTIGIT_REPO" || exit 1
find . ! -path './.git/*' ! -path './.git' ! -path '.' -exec rm -rf {} \; 2>/dev/null)
}
git_make_hardlinks() {
git_remove_links
git ls-files | while read f; do
mkdir -p "$(dirname ".mgit/$MULTIGIT_REPO/$f")"
ln -f "$f" ".mgit/$MULTIGIT_REPO/$f"
done
}
git_make_symlinks() {
git_remove_links
git ls-files | while read f; do
mkdir -p "$(dirname ".mgit/$MULTIGIT_REPO/$f")"
ln -sf "$PWD/$f" ".mgit/$MULTIGIT_REPO/$f"
done
}
git_cmd_one() {
local repo="$1"
local cmd="$2"
shift 2
[ "$VERBOSE" ] && say "@$repo:"
export GIT_DIR="$PWD/.mgit/$repo/.git"
export MULTIGIT_REPO="$repo"
[ -d "$GIT_DIR" ] || die "Unknown repo: '$repo'."
case "$cmd" in
exec) cd "$PWD0" && "$@" ;;
ver) git_ver "$@" ;;
version) git_version "$@" ;;
make-symlinks) git_make_symlinks "$@" ;;
make-hardlinks) git_make_hardlinks "$@" ;;
"") git_shell "$@" ;;
*)
# look for and execute a git plugin command
if [ -f ".mgit/git-$cmd.sh" ]; then
PWD0="$PWD0" ".mgit/git-$cmd.sh" "$@"
else
(cd "$PWD0" && git "$cmd" "$@")
fi
;;
esac
}
git_cmd() {
local repos="$1"; shift
if [ "$repos" = "--all" ]; then
[ "$1" ] || die "Refusing to start a subshell for each repo."
for repo in `list_cloned`; do
git_cmd_one "$repo" "$@"
done
else
(IFS=","; for repo in $repos; do
git_cmd_one "$repo" "$@"
done)
fi
[ "$1" = "ver" ] && say
}
cd_root() {
local pwd1
while [ "$PWD" != "$pwd1" ]; do
[ -d .mgit ] && return
pwd1="$PWD"
cd .. || die "Could not cd to '$PWD/..'."
done
cd "$PWD0" # root dir not found, go back to initial dir
}
PWD0="$PWD"
cd_root
while true; do
case "$1" in
-v) shift; export VERBOSE=1;;
--debug) shift; export DEBUG=1;;
--dry) shift; export DRY=1;;
--yes) shift; export YES=1;;
-SS) shift; [ "$OSTYPE" != "msys" ] && export GIT_SSH_COMMAND="ssh -o ControlMaster=auto -o ControlPersist=10 -o ControlPath=~/.ssh/sock-mgit";;
-P) shift; export MAXPROC="$1"; shift;;
*) break;;
esac
done
[ "$DEBUG" ] || export GITOPT=-q
[ "$MAXPROC" ] || export MAXPROC=1
cmd="$1"; shift
case "$cmd" in
"") usage ;;
help) usage ;;
--help) usage ;;
ls) list_cloned ;;
ls-all) list_known ;;
ls-uncloned) list_uncloned ;;
ls-modified) list_modified "$@" ;;
st) list_modified "$@" ;;
status) list_modified "$@" ;;
which) which_tracks "$@" ;;
ls-unpushed) list_unpushed ;;
ls-untracked) list_untracked ;;
ls-double-tracked) list_double_tracked ;;
ls-tracked) list_tracked ;;
init) init "$@" ;;
clone) clone "$@" ;;
clone-all) clone_all "$@" ;;
clone-release) clone_release "$@" ;;
remove) remove "$@" ;;
convert) convert "$@" ;;
baseurl) baseurl "$@" ;;
origin) origin "$@" ;;
release) release "$@" ;;
bash) cd "$PWD0" && exec bash "$@" ;;
-) git_cmd "$@" ;;
*)
# look for and execute a plugin command
if [ -f ".mgit/$cmd.sh" ]; then
".mgit/$cmd.sh" "$@"
else
git_cmd "$cmd" "$@"
fi
;;
esac