Skip to content

Instantly share code, notes, and snippets.

@ashwch
Last active August 7, 2025 21:28
Show Gist options
  • Select an option

  • Save ashwch/909ea473250e8c8a937a8a4aa4a4dc72 to your computer and use it in GitHub Desktop.

Select an option

Save ashwch/909ea473250e8c8a937a8a4aa4a4dc72 to your computer and use it in GitHub Desktop.
Git Submodule Updater - Automates updating all submodules to their latest commits with branch configuration support
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.8"
# dependencies = [
# "click>=8.0",
# "rich>=13.0",
# ]
# ///
"""
Update all submodules to their respective default branches.
This script automates the process of updating all Git submodules in a repository
to their latest commits on their default (or configured) branches. It can be
customized with a configuration file for specific branch mappings.
FEATURES:
---------
- Automatically detects the default branch for each submodule
- Supports custom branch configuration via JSON file
- Dry-run mode to preview changes before applying
- Rich terminal output with progress tracking
- Automatic commit of submodule updates with detailed message
USAGE:
------
Update all submodules to latest:
python update_submodules.py
Preview changes without applying:
python update_submodules.py --dry-run
Create example configuration:
python update_submodules.py --create-config
Use custom configuration:
python update_submodules.py --config my-branches.json
CONFIGURATION:
--------------
Create a .submodule-branches.json file in your repository root:
{
"frontend": "develop",
"backend": "main",
"shared": "release"
}
Author: Diversio Engineering Team
License: MIT
"""
import subprocess
import json
from pathlib import Path
from typing import Dict, Tuple, Optional, List
try:
import click
from rich.console import Console
from rich.table import Table
from rich.progress import Progress, SpinnerColumn, TextColumn
except ImportError:
print("Error: Required dependencies not installed.")
print("Install with: pip install click rich")
import sys
sys.exit(1)
console = Console()
def run_command(cmd: str, cwd: Optional[Path] = None, capture: bool = True) -> Tuple[str, int]:
"""
Run a shell command and return output and return code.
Args:
cmd: Command to run
cwd: Working directory for the command
capture: Whether to capture output
Returns:
Tuple of (output, return_code)
"""
if capture:
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
cwd=cwd
)
return result.stdout.strip(), result.returncode
else:
result = subprocess.run(cmd, shell=True, cwd=cwd)
return "", result.returncode
def get_default_branch(submodule_path: Path) -> str:
"""
Detect the default branch for a submodule.
Tries multiple strategies:
1. Check remote HEAD reference
2. Look for common branch names (main, master, develop)
3. Fall back to current branch
Args:
submodule_path: Path to the submodule
Returns:
Name of the default branch
"""
# First, try to get from remote HEAD
output, code = run_command(
"git symbolic-ref refs/remotes/origin/HEAD",
cwd=submodule_path
)
if code == 0 and output:
return output.replace("refs/remotes/origin/", "")
# Try common branch names
common_branches = ["main", "master", "develop", "release", "production"]
for branch in common_branches:
output, code = run_command(
f"git show-ref --verify refs/remotes/origin/{branch}",
cwd=submodule_path
)
if code == 0:
return branch
# Fall back to current branch
output, _ = run_command(
"git rev-parse --abbrev-ref HEAD",
cwd=submodule_path
)
return output or "main"
def load_branch_config(config_path: Optional[Path] = None) -> Dict[str, str]:
"""
Load custom branch configuration from a JSON file.
Args:
config_path: Path to configuration file (optional)
Returns:
Dictionary mapping submodule names to branch names
"""
if config_path:
path = Path(config_path)
else:
# Look for default configuration files
for filename in [".submodule-branches.json", ".submodule-branches"]:
path = Path(filename)
if path.exists():
break
else:
return {}
if path.exists():
try:
with open(path) as f:
return json.load(f)
except json.JSONDecodeError as e:
console.print(f"[red]Error parsing {path}: {e}[/red]")
return {}
return {}
def get_submodules() -> List[str]:
"""
Get list of all submodules in the repository.
Returns:
List of submodule paths
"""
output, code = run_command("git submodule status")
if code != 0 or not output:
return []
submodules = []
for line in output.splitlines():
parts = line.strip().split()
if len(parts) >= 2:
# The path is the second element (after the commit hash)
path = parts[1]
submodules.append(path)
return submodules
def create_example_config() -> None:
"""Create an example configuration file."""
example = {
"frontend": "main",
"backend": "develop",
"shared-components": "main",
"infrastructure": "master",
"_comment": "Map submodule paths to their target branches"
}
config_path = Path(".submodule-branches.example.json")
with open(config_path, "w") as f:
json.dump(example, f, indent=2)
console.print(f"[green]✅ Created {config_path}[/green]")
console.print("\nExample configuration:")
console.print(json.dumps(example, indent=2))
console.print("\n[dim]Copy to .submodule-branches.json and customize as needed[/dim]")
@click.command()
@click.option(
'--create-config',
is_flag=True,
help='Create an example configuration file'
)
@click.option(
'--dry-run',
is_flag=True,
help='Show what would be updated without making changes'
)
@click.option(
'--config',
type=click.Path(exists=True),
help='Path to custom branch configuration file'
)
@click.option(
'--commit/--no-commit',
default=True,
help='Automatically commit submodule updates (default: commit)'
)
def update_submodules(create_config: bool, dry_run: bool, config: Optional[str], commit: bool):
"""Update all submodules to their latest commits on their default branches."""
if create_config:
create_example_config()
return
# Print header
if dry_run:
console.print("[bold yellow]🔍 DRY RUN MODE - No changes will be made[/bold yellow]\n")
console.print("[bold blue]🔄 Updating submodules to their latest commits...[/bold blue]\n")
# Load configuration
branch_config = load_branch_config(Path(config) if config else None)
if branch_config:
console.print(f"[dim]Using branch configuration from {config or '.submodule-branches.json'}[/dim]\n")
# Get list of submodules
submodules = get_submodules()
if not submodules:
console.print("[yellow]No submodules found in this repository.[/yellow]")
return
console.print(f"Found {len(submodules)} submodule(s)\n")
# Create results table
table = Table(title="Submodule Update Results")
table.add_column("Submodule", style="cyan", no_wrap=True)
table.add_column("Branch", style="magenta")
table.add_column("Previous", style="dim")
table.add_column("Current", style="green")
table.add_column("Status", style="bold")
updated = []
errors = []
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task = progress.add_task("Processing submodules...", total=len(submodules))
for submodule in submodules:
progress.update(task, description=f"Processing {submodule}...")
submodule_path = Path(submodule)
if not submodule_path.exists():
errors.append(f"{submodule}: Directory not found")
table.add_row(
submodule,
"N/A",
"N/A",
"N/A",
"[red]Not found[/red]"
)
progress.advance(task)
continue
# Fetch latest changes
if not dry_run:
output, code = run_command("git fetch origin --quiet", cwd=submodule_path)
if code != 0:
errors.append(f"{submodule}: Failed to fetch")
table.add_row(
submodule,
"N/A",
"N/A",
"N/A",
"[red]Fetch failed[/red]"
)
progress.advance(task)
continue
# Determine branch to use
if submodule in branch_config:
branch = branch_config[submodule]
else:
branch = get_default_branch(submodule_path)
# Get current commit
old_commit, _ = run_command(
"git rev-parse --short HEAD",
cwd=submodule_path
)
if not dry_run:
# Checkout and pull the target branch
checkout_output, checkout_code = run_command(
f"git checkout {branch} --quiet",
cwd=submodule_path
)
if checkout_code != 0:
# Try to create the branch if it doesn't exist locally
run_command(
f"git checkout -b {branch} origin/{branch} --quiet",
cwd=submodule_path
)
pull_output, pull_code = run_command(
f"git pull origin {branch} --quiet",
cwd=submodule_path
)
if pull_code != 0:
errors.append(f"{submodule}: Failed to pull {branch}")
table.add_row(
submodule,
branch,
old_commit,
old_commit,
"[red]Pull failed[/red]"
)
progress.advance(task)
continue
# Get new commit (or simulate for dry run)
if dry_run:
# Get the remote branch's latest commit
new_commit, _ = run_command(
f"git rev-parse --short origin/{branch}",
cwd=submodule_path
)
else:
new_commit, _ = run_command(
"git rev-parse --short HEAD",
cwd=submodule_path
)
# Record results
if old_commit != new_commit:
updated.append({
"name": submodule,
"old": old_commit,
"new": new_commit,
"branch": branch
})
status = "[yellow]Would update[/yellow]" if dry_run else "[green]Updated[/green]"
table.add_row(submodule, branch, old_commit, new_commit, status)
else:
table.add_row(submodule, branch, old_commit, new_commit, "[dim]Up to date[/dim]")
progress.advance(task)
# Display results
console.print()
console.print(table)
# Show errors if any
if errors:
console.print("\n[red]Errors encountered:[/red]")
for error in errors:
console.print(f" • {error}")
# Stage and commit changes if needed
if updated and not dry_run:
if commit:
# Stage all submodule changes
run_command("git add .")
# Create detailed commit message
commit_lines = ["Update submodules to latest commits", ""]
for update in updated:
commit_lines.append(
f"- {update['name']}: {update['old']}{update['new']} ({update['branch']})"
)
commit_message = "\n".join(commit_lines)
# Create the commit
run_command(f'git commit -m "{commit_message}"')
console.print(f"\n[green]✅ Committed {len(updated)} submodule update(s)[/green]")
else:
console.print(f"\n[yellow]⚠️ {len(updated)} submodule(s) updated but not committed[/yellow]")
console.print("[dim]Run 'git add . && git commit' to commit the changes[/dim]")
elif updated and dry_run:
console.print(f"\n[yellow]🔍 Dry run complete. {len(updated)} submodule(s) would be updated.[/yellow]")
console.print("[dim]Run without --dry-run to apply changes.[/dim]")
else:
console.print("\n[green]✅ All submodules are already up to date[/green]")
if __name__ == "__main__":
try:
update_submodules()
except KeyboardInterrupt:
console.print("\n[red]Interrupted by user.[/red]")
import sys
sys.exit(1)
except Exception as e:
console.print(f"\n[red]Unexpected error: {e}[/red]")
import sys
sys.exit(1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment