#!/usr/bin/env bash unset CDPATH IFS=$' \t\n' if type gettext &>/dev/null; then _() { gettext "$@"; } else _() { echo "$@"; } fi print() { printf "$(_ "$1")\n" "${@:2}" } usage() { local varname=use_$1 print "${!varname}" | sed 's|\$dashless|'"$dashless|g" } # # low-ish level smh commands use_toplevel='usage: $dashless Find the most-parent git repository of (possibly nested) git submodules. That is, it is like a submodule-aware `git rev-parse --show-toplevel`.' cmd_toplevel() { local mode local args if ! args=$(getopt -n "$dashless" -o h -- "$@"); then mode=err else eval set -- "$args" local mode=normal while true; do case "$1" in -h) shift; mode=help;; --) shift; break;; esac done if [[ $# -gt 0 ]]; then mode=err fi fi case "$mode" in err) usage toplevel; return 2;; help) usage toplevel;; normal) smh-toplevel;; esac } smh-toplevel() ( local gitdir local cdup while true; do gitdir="$(git rev-parse --git-dir)" || return $? cdup="$(git rev-parse --show-cdup)" || return $? [[ -z "$cdup" ]] || cd "$cdup" || return $? if [[ "$gitdir" != */.git/modules/* ]]; then break fi cd .. || return $? done pwd ) use_split='$dashless [-H|-z] [--] ... Split a list of filenames into submodule-path/file-path pairs. If neither -H nor -z is given, it will assume -H if stdout is a TTY, or -z otherwise. -H human friendly; use spaces and newlines in the output -z separate output with NUL character' cmd_split() { local mode local args if ! args=$(getopt -n "$dashless" -o hHz -- "$@"); then mode=err else eval set -- "$args" local mode if [[ -t 1 ]]; then mode=human else mode=machine fi while true; do case "$1" in -h) shift; mode=help;; -H) shift; [[ "$mode" = help ]] || mode=human;; -z) shift; [[ "$mode" = help ]] || mode=machine;; --) shift; break;; esac done fi case "$mode" in err) usage split >&2; return 2;; help) usage split;; machine) smh-split "$@";; human) set -o pipefail smh-split "$@" | xargs -0r printf '%q %q\n' ;; esac } smh-split() { if [[ $# = 0 ]]; then return 0 fi local toplevel toplevel="$(smh-toplevel)" || return $? local cwd files cwd_files=("$@") declare -i i=0 local smh_file cwd_file local gittop while read -r -d '' smh_file; do cwd_file="${cwd_files[$i]}" if [[ "$smh_file" = ../* ]] || ! [[ -e "$smh_file" ]]; then # let git print the error message git add "$cwd_file" return $? else if [[ -d "$cwd_file" ]]; then gittop="$((cd "$cwd_file"; git --show-toplevel))" else gittop="$((cd "$(dirname -- "$cwd_file")"; git --show-toplevel))" fi printf '%s\0' \ "$(realpath --no-symlinks --relative-to . "$gittop")" \ "$(realpath --no-symlinks --relative-to "$gittop" "$cwd_file")" fi i+=1 done < <(realpath -z --canonicalize-missing --no-symlinks --relative-to "$toplevel" -- "${cwd_files[@]}") } use_foreach='usage: $dashless Run a command in the root of the repository, and in each submodule. This is similar to `git submodule foreach`, but with two important differences: 1. It also runs the command in the parent repository 2. It runs the command in the deepest repositories first, then the parents. This is backward of `git submodule foreach`. Bugs: 1. Does not yet set the $name, $path, $sha1 and $toplevel variables that `git submodule foreach` does.' cmd_foreach() { local mode local args if ! args=$(getopt -n "$dashless" -o h -- "$@"); then mode=err else eval set -- "$args" local mode=normal while true; do case "$1" in -h) shift; mode=help;; --) shift; break;; esac done if [[ $# -gt 0 ]]; then mode=err fi fi case "$mode" in err) usage foreach >&2; return 2;; help) usage foreach;; normal) smh-toplevel;; esac } smh-foreach() { local cmd="$1" exec 3<&0 ( cd "$(smh-toplevel)" || return $? export LC_ALL=C git submodule foreach --quiet --recursive pwd | sort --reverse pwd ) | xargs -0 realpath -zs --relative-to=. | while read -r -d '' path; do ( cd "$path" && sh -c "$cmd" ) <&3 3<&- || { print "Stopping at '%s'; script returned non-zero status." "$path" return 1 } done } # # smh add smh-add--helper() { local file extra file="$(git rev-parse --git-path SMH_ADD)" read -a extra -r -d '' < "$file" rm -f "$file" exec git add "$@" "${extra[@]}" } use_add='usage: $dashless [options] [--] files Like `git add`, but intelligently changes directory to the correct submodule. -i, --interactive interactive picking -p, --patch select hunks interactively -e, --edit edit current diff and apply (not currently implemented) -v, --verbose be verbose -f, --force allow adding otherwise ignored files -u, --update update tracked files -N, --intent-to-add record only the fact that the path will be added later -A, --all add changes from all tracked and untracked files --ignore-removal ignore paths removed in the working tree (same as --no-all) --refresh don'\''t add, only refresh the index --ignore-errors just skip files which cannot be added because of errors' smh-add() { local mode=batch local gitflags=() local args if ! args=$(getopt -n "$dashless" -o hvfipeuAN -l verbose,force,interactive,patch,edit,update,all,no-ignore-removal,no-all,ignore-removal,intent-to-add,refresh,ignore-errors -- "$@"); then mode=err else eval set -- "$args" while true; do case "$1" in -h) mode=help;; --interactive|-i) [[ "$mode" = help ]] || mode=interactive;; --patch|-p) [[ "$mode" = help ]] || mode=patch;; --edit|-e) [[ "$mode" = help ]] || mode=edit;; --verbose|-v) gitflags+=("$1");; --force|-f) gitflags+=("$1");; --update|-u) gitflags+=("$1");; -A|--all|-no-ignore-removal) gitflags+=("$1");; --no-all|--ignore-removal) gitflags+=("$1");; --intent-to-add|-N) gitflags+=("$1");; --refresh) gitflags+=("$1");; --ignore-errors) gitflags+=("$1");; --) shift; break;; esac shift done fi if [[ "$mode" != err ]] && [[ "$mode" != help ]]; then while read -r -d '' dir && read -r -d '' file; do printf '%s\0' "$file" >> "$(cd "$dir" && git rev-parse --git-path 'SMH_ADD')" done < <(smh-split "$@") fi local cmd case "$mode" in err) usage add >&2; return 2;; help) usage add;; batch) printf -v cmd '%q ' git smh add--helper "${gitflags[@]}" -- smh-foreach "$cmd" ;; interactive) gitflags=(-i "${gitflags[@]}") printf -v cmd '%q ' git smh add--helper "${gitflags[@]}" -- printf -v cmd '%q ' git smh foreach "$cmd" sed -r '/^(7|q|qu|qui|quit)$/q' | script --return --quiet -c "$cmd" /dev/null ;; patch) gitflags=(-p "${gitflags[@]}") printf -v cmd '%q ' git smh add--helper "${gitflags[@]}" -- printf -v cmd '%q ' git smh foreach "$cmd" sed '/^q$/q' | script --return --quiet -c "$cmd" /dev/null ;; edit) gitflags=(-e "${gitflags[@]}") print 'not implemented: smh add --edit' >&2 return 4 ;; esac } # # smh commit smh-commit--helper() { git commit "$@" && cd .. && git add "$OLDPWD" } use_commit='usage: $dashless [options] Recursively commit in each submodule (and parent repository). All arguments are passed directly to `git commit`, so you should avoid using this command to add files.' smh-commit() { local cmd printf -v '%q ' git smh commit--helper "$@" smh-foreach "$cmd" } # # smh push use_push='usage: $dashless [options] Recursively push in each submodule (and parent repository). All arguments are passed directly to `git push`.' smh-push() { local cmd printf -v '%q ' git push "$@" smh-foreach "$cmd" } # main() { dashless=$(basename "$0" | sed -e 's/-/ /') local cmd="$1"; shift case "$cmd" in -h) local _dashless="$dashless" for cmd in toplevel split foreach add commit push; do dashless="$_dashless $cmd" usage "$cmd" done | grep -e '^usage: ' -e '^ or: ' | sed '2,$s/^usage: / or: /' ;; toplevel|split|foreach) cmd_"$cmd" "$@" ;; add|commit|push) smh-"$cmd" "$@" ;; add--helper|commit--helper) smh-"$cmd" "$@" ;; *) print 'error: Unknown subcommand: %s' "$cmd" >&2 usage >&2 return 2 ;; esac } main "$@"