Skip to content

Git Submodules Management Script

Complete toolkit for managing Git submodules with bulk operations and GitHub integration

Metadata

Code

bash
#!/usr/bin/env bash

# @title Git Submodules Management Script
# @description Complete toolkit for managing Git submodules with bulk operations and GitHub integration
# @author ropean
# @version 1.0.0
# @date 2025-10-30
#
# ==============================================================
# Git Submodules Management Script (English)
# --------------------------------------------------------------
#  • Configurable GitHub user name
#  • Bulk repo creation with single visibility choice
#  • Optional .gitmodules update
#  • Init / Push / Pull / Run-in-all with clear feedback
# ==============================================================
#
# @requires gh (GitHub CLI)
# @see https://cli.github.com/

# --------------------- CONFIGURATION ---------------------
GITHUB_USER="ropean"  # <<<--- CHANGE THIS
SUBMODULES_DIR="apps" # Submodule root (change if needed)
GITMODULES_FILE=".gitmodules"
# ---------------------------------------------------------

# --------------------- COLOUR & EMOJI --------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
DIM='\033[2m'
NC='\033[0m' # No colour
INFO="ℹ️  "
SUCCESS="✅ "
WARN="⚠️  "
ERR="❌  "
RUN="🔧  "

# --------------------- PRINT HELPERS --------------------
p_header() { echo -e "${PURPLE}=======================================${NC}"; }
p_title() { echo -e "${CYAN}   Git Submodules Manager${NC}"; }
p_success() { echo -e "${SUCCESS}${GREEN}$1${NC}"; }
p_error() { echo -e "${ERR}${RED}$1${NC}"; }
p_warn() { echo -e "${WARN}${YELLOW}$1${NC}"; }
p_info() { echo -e "${INFO}${BLUE}$1${NC}"; }
p_dim() { echo -e "${INFO}${DIM}$1${NC}"; }
p_run() { echo -e "${RUN}${CYAN}$1${NC}"; }

# --------------------- UTILS --------------------
# List submodules with numbers
list_submodules() {
	find "$SUBMODULES_DIR" -mindepth 1 -maxdepth 1 -type d | sort | nl -w2 -s') '
}
# Return array of submodule names only
submodule_names() {
	find "$SUBMODULES_DIR" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort
}

# --------------------- GH CLI CHECK --------------------
check_gh() {
	if ! command -v gh &>/dev/null; then
		p_error "GitHub CLI (gh) is not installed!"
		echo "    Install from: https://cli.github.com"
		return 1
	fi
	return 0
}

# --------------------- CHECK & UPDATE SUBMODULES_DIR --------------------
check_and_update_submodules_dir() {
	if [[ -d "$SUBMODULES_DIR" ]]; then
		return 0
	fi

	p_warn "Directory '$SUBMODULES_DIR' does not exist!"
	echo -n "Enter the submodules directory path: "
	read -r new_dir

	# Trim whitespace
	new_dir=$(echo "$new_dir" | xargs)

	if [[ -z "$new_dir" ]]; then
		p_error "Directory path cannot be empty"
		return 1
	fi

	if [[ ! -d "$new_dir" ]]; then
		echo -n "Directory '$new_dir' doesn't exist. Create it? [y/N]: "
		read -r create_choice
		case "$create_choice" in
		y | Y)
			if mkdir -p "$new_dir"; then
				p_success "Created directory: $new_dir"
			else
				p_error "Failed to create directory: $new_dir"
				return 1
			fi
			;;
		*)
			p_error "Operation cancelled"
			return 1
			;;
		esac
	fi

	# Update the script itself
	local script_path="${BASH_SOURCE[0]}"
	if [[ -w "$script_path" ]]; then
		p_info "Updating SUBMODULES_DIR in script..."
		if sed -i.bak "s|^SUBMODULES_DIR=\".*\"|SUBMODULES_DIR=\"$new_dir\"|" "$script_path"; then
			p_success "Script updated! SUBMODULES_DIR is now: $new_dir"
			# Update the current session variable
			SUBMODULES_DIR="$new_dir"
			rm -f "${script_path}.bak"
		else
			p_error "Failed to update script"
			return 1
		fi
	else
		p_warn "Cannot write to script file. Using '$new_dir' for this session only."
		SUBMODULES_DIR="$new_dir"
	fi

	return 0
}

# --------------------- OPTIONAL .gitmodules UPDATE --------------------
update_gitmodules() {
	p_info "Upserting missing entries into $GITMODULES_FILE..."
	# Ensure file exists
	touch "$GITMODULES_FILE"

	local count=0
	while IFS= read -r dir; do
		local name
		name=$(basename "$dir")
		local path="$SUBMODULES_DIR/$name"
		local url="[email protected]:$GITHUB_USER/$name.git"

		if grep -q "path = $path" "$GITMODULES_FILE" 2>/dev/null; then
			p_dim "Entry for $path already exists in $GITMODULES_FILE — skipping"
			continue
		fi

		cat >>"$GITMODULES_FILE" <<EOF
[submodule "$path"]
	path = $path
	url = $url
EOF
		((count++))
	done < <(find "$SUBMODULES_DIR" -mindepth 1 -maxdepth 1 -type d)

	if ((count > 0)); then
		git add "$GITMODULES_FILE" &>/dev/null || true
		p_success "Appended $count missing submodule(s) to $GITMODULES_FILE."
	else
		p_success "No new submodules."
	fi
}

# --------------------- 1. INITIALIZE ALL SUBMODULES --------------------
init_all_submodules() {
	p_info "Initializing all submodules in $SUBMODULES_DIR (git init only)..."
	local total=0 failed=()
	while IFS= read -r dir; do
		((total++))
		local name
		name=$(basename "$dir")
		if (cd "$dir" && git rev-parse --is-inside-work-tree &>/dev/null); then
			p_dim "[$name] already a git repo — skipping"
			continue
		fi
		if (cd "$dir" && git init -q); then
			p_success "Initialized $name"
		else
			failed+=("$name")
			p_error "Failed to init $name"
		fi
	done < <(find "$SUBMODULES_DIR" -mindepth 1 -maxdepth 1 -type d)

	echo
	p_success "Initialized $((total - ${#failed[@]})) / $total submodules."
	((${#failed[@]})) && p_error "Failed: ${failed[*]}"
}

# --------------------- 2. CREATE GITHUB REPOS (bulk) --------------------
create_github_repos() {
	check_gh || return 1

	# === 1. Get submodule names ===
	local names
	mapfile -t names < <(submodule_names)
	((${#names[@]} == 0)) && {
		p_error "No submodules found in $SUBMODULES_DIR"
		return 1
	}

	# === 2. Set visibility uniformly (ask only once) ===
	local VIS="private"
	echo -n "Repository visibility [P]rivate / [p]ublic (default private): "
	read -r vis_input
	case "$vis_input" in
	p | P | public | Public) VIS="public" ;;
	*) VIS="private" ;;
	esac

	# === 3. Fetch all user repositories at once (max 1000, sufficient) ===
	p_info "Fetching existing repositories for '$GITHUB_USER'..."
	local existing_raw
	if ! existing_raw=$(gh repo list "$GITHUB_USER" --limit 1000 --json name -q '.[].name' 2>/dev/null); then
		p_error "Failed to fetch repository list. Is 'gh' authenticated?"
		return 1
	fi

	# Build existence map
	declare -A exists_map
	while IFS= read -r repo_name; do
		[[ -n "$repo_name" ]] && exists_map["$repo_name"]=1
	done <<<"$existing_raw"

	# === 4. Filter out repositories that need to be created ===
	local to_create=()
	for name in "${names[@]}"; do
		if [[ -z "${exists_map[$name]}" ]]; then
			to_create+=("$name")
		else
			p_dim "Repo '$name' already exists — skipping"
		fi
	done

	((${#to_create[@]} == 0)) && {
		p_success "All ${#names[@]} repositories already exist."
		return 0
	}

	# === 5. Batch create and push ===
	p_info "Creating ${#to_create[@]} new $VIS repositories..."
	local created=0 failed=()

	for repo in "${to_create[@]}"; do
		local dir="$SUBMODULES_DIR/$repo"
		[[ ! -d "$dir" ]] && {
			p_error "Directory missing: $dir"
			failed+=("$repo")
			continue
		}

		printf "${YELLOW}→ Creating %s ($VIS)${NC}\n" "$repo"
		if gh repo create "$repo" \
			--$VIS \
			--source="$dir" \
			--remote=origin \
			--description "Submodule: $repo"; then

			p_success "$repo created"
			((created++))
		else
			p_error "$repo creation failed"
			failed+=("$repo")
		fi
	done

	# === 6. Final statistics ===
	echo
	p_success "Created $created out of ${#to_create[@]} repositories."
	((${#failed[@]})) && p_error "Failed: ${failed[*]}"
}

# --------------------- 5. RUN COMMAND IN ALL --------------------
run_in_all() {
	local cmd="$*"

	if [[ -z "$cmd" ]]; then
		echo -n "Enter command to run in every submodule: "
		read -r cmd
		[[ -z "$cmd" ]] && {
			p_error "Command cannot be empty"
			return 1
		}
	fi

	p_run "$cmd"
	local total=0 ok=0 fail=() args
	while IFS= read -r dir; do
		((total++))
		name=$(basename "$dir")
		echo -e "\n${YELLOW}[$name] $cmd${NC}"
		if (cd "$dir" &&
			eval "args=($cmd)" &&
			[ ${#args[@]} -gt 0 ] &&
			"${args[@]}"); then
			# "${args[@]}" >/dev/null 2>&1); then
			((ok++))
			p_success "$name"
		else
			fail+=("$name")
			p_error "$name"
		fi
	done < <(find "$SUBMODULES_DIR" -mindepth 1 -maxdepth 1 -type d)

	echo
	p_success "Executed on $ok / $total submodules."
	((${#fail[@]})) && p_error "Failed: ${fail[*]}"
}

# --------------------- 6. HELP --------------------
show_help() {
	cat <<'EOF'

Common Git Submodule Commands
-----------------------------
git submodule add <url> <path>          Add a submodule
git submodule init                      Initialise .gitmodules
git submodule update                    Checkout submodules
git submodule update --remote           Pull latest remote
git submodule foreach <cmd>             Run <cmd> in every submodule
git submodule status                    Show status
git rm --cached <path>                  Remove submodule (keep folder)

Full docs: https://git-scm.com/book/en/v2/Git-Tools-Submodules
EOF
}

fix_git_remote() {
	[[ -z "$GITHUB_USER" ]] && {
		p_error "GITHUB_USER is not set"
		return 1
	}

	local total=0 ok=0 fail=() name url current_origin

	while IFS= read -r dir; do
		((total++))
		name=$(basename "$dir")
		url="https://github.com/$GITHUB_USER/$name.git"

		p_run "[$name] Fixing remote origin -> $url${NC}"

		if ! current_origin=$(cd "$dir" && git remote get-url origin 2>/dev/null); then
			# No origin exists → add it
			if (cd "$dir" && git remote add origin "$url" >/dev/null 2>&1); then
				((ok++))
				p_success "$name (added origin)"
			else
				fail+=("$name")
				p_error "$name (failed to add origin)"
			fi
		elif [[ "$current_origin" != "$url" ]]; then
			# Origin exists but wrong URL → set-url
			if (cd "$dir" && git remote set-url origin "$url" >/dev/null 2>&1); then
				((ok++))
				p_success "$name (updated origin)"
			else
				fail+=("$name")
				p_error "$name (failed to set-url)"
			fi
		else
			# Origin already correct
			((ok++))
			p_dim "$name (origin already correct)"
		fi
	done < <(find "$SUBMODULES_DIR" -mindepth 1 -maxdepth 1 -type d)

	echo
	p_success "Fixed remote on $ok / $total submodules."
	((${#fail[@]})) && p_error "Failed: ${fail[*]}"
}

# --------------------- MAIN MENU --------------------
main_menu() {
	# Check if SUBMODULES_DIR exists before showing menu
	check_and_update_submodules_dir || exit 1

	while true; do
		clear
		p_header
		p_title
		p_header
		echo -e "${DIM}Current submodules directory: ${CYAN}$SUBMODULES_DIR${NC}"
		p_header
		echo "1) Upsert .gitmodules"
		echo "2) Create GitHub repos"
		echo "3) Run command in ALL submodules"
		echo "4) Show submodule commands"
		echo "5) Git init all submodules"
		echo "6) Git pull all submodules"
		echo "7) Git status all  submodules"
		echo "9) Fix git remote for all submodules"
		echo "0) Exit"
		echo -e "${PURPLE}----------------------------------------${NC}"
		echo -n "Choose [0-7]: "
		read -r opt

		case "$opt" in
		1) update_gitmodules ;;
		2) create_github_repos ;;
		3) run_in_all ;;
		4) show_help ;;
		5) run_in_all git init ;;
		6) run_in_all git pull ;;
		7) run_in_all git status ;;
		9) fix_git_remote ;;
		0)
			echo -e "${SUCCESS}Goodbye!${NC}"
			exit 0
			;;
		*) p_error "Invalid option — try again" ;;
		esac

		echo -e "\n${YELLOW}Press ENTER to continue…${NC}"
		read -r
	done
}

# --------------------- START --------------------
main_menu

File Information

  • Filename: git-submodules.sh
  • Category: git
  • Language: BASH

View on GitHub