Last active
August 7, 2025 21:28
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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