Skip to content

Instantly share code, notes, and snippets.

@tobiashochguertel
Last active April 17, 2026 14:19
Show Gist options
  • Select an option

  • Save tobiashochguertel/e07a0a79b3ce8e7d157405d6845e473e to your computer and use it in GitHub Desktop.

Select an option

Save tobiashochguertel/e07a0a79b3ce8e7d157405d6845e473e to your computer and use it in GitHub Desktop.
git-submodule-manager: Comprehensive Git Submodule Management Tool - PEP 723 uv inline script with rich output and workflow automation
# git-submodule-manager
Comprehensive Git Submodule Management Tool
A PEP 723 uv inline Python script for managing git submodules with:
• Rich terminal output with tables and colors
• Status checking, workflow automation, JSON output
• Automated pull/push workflows with correct order
Install: curl -fsSL https://gist.github.com/tobiashochguertel/e07a0a79b3ce8e7d157405d6845e473e/raw/install.sh | bash
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info/
dist/
build/
.pytest_cache/
.coverage
htmlcov/
.tox/
.venv/
venv/
ENV/
env/

git-submodule-manager

Comprehensive Git Submodule Management Tool

A standalone Python script (PEP 723 uv inline, minimal external deps) that provides comprehensive git submodule status checking, workflow automation, and actionable recommendations.

Quick Install

curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/f859836fb27910e328cc653f45838354/raw/install.sh | bash

Installs to ~/.taskfiles/taskscripts/git-submodule-manager/ (a real git repo — updatable with a single command).

Features

  • Status Checking: Comprehensive status of parent and all submodules
  • Rich Output: Colored terminal output with tables and panels
  • JSON Support: Machine-readable output for automation
  • Workflow Automation: Built-in pull and push workflows
  • Actionable Recommendations: Get specific next steps based on repository state
  • Zero Dependencies: Uses uv run with inline PEP 723 metadata

Usage

Check Repository Status

# Check current directory
git-submodule-manager check

# Check specific repository
git-submodule-manager check /path/to/repo

# Detailed output
git-submodule-manager check --verbose

# JSON output for automation
git-submodule-manager check --json

Quick Status (Only Dirty Repos)

git-submodule-manager quick

List Submodules

git-submodule-manager list-submodules

Execute Workflows

# Pull workflow: fetch all, pull parent, update submodules
git-submodule-manager workflow-pull

# Push workflow: commit submodules, push, commit parent, push
git-submodule-manager workflow-push -m "Your commit message"

Output Example

╭──────────────────────────────────────────────────────────╮
│  Git Submodule Manager                                   │
│  my-project                                              │
╰──────────────────────────────────────────────────────────╯

┌ Repository: my-project ─────────────────────────────────┐
│ Property        │ Value                                  │
│ ─────────────────────────────────────────────────────── │
│ Path            │ /home/user/projects/my-project         │
│ Branch          │ main                                   │
│ Status          │ ✓ Clean                                │
│ Modified files  │ 0                                      │
│ Staged files    │ 0                                      │
│ Untracked files │ 0                                      │
└─────────────────────────────────────────────────────────┘

Submodules (3):
┌──────────────────┬─────────────────┬──────────────┬─────────────────┬────────┐
│ Name             │ Path            │ Commit       │ Status          │ Branch │
├──────────────────┼─────────────────┼──────────────┼─────────────────┼────────┤
│ mcps-service     │ mcps-service/   │ 19cada15eea6 │ + Modified      │ main   │
│ docs-site        │ opencode-docs-… │ 15f055fa08e6 │ [green]  Current       │ main   │
│ proxy            │ opencode-proxy/ │ 0252ef6804b6 │ ⚠ (detached)    │ -      │
└──────────────────┴─────────────────┴──────────────┴─────────────────┴────────┘

Recommendations:
  • Stage mcps-service update: git add mcps-service
  • proxy is detached - create a branch to make changes

Understanding Submodule Status

The git submodule status output uses prefixes:

Prefix Meaning
+ Submodule has new commits (different from parent tracking)
- Submodule not initialized (empty directory)
Submodule matches tracked commit
U Merge conflict

Workflow Best Practices

Pulling Changes (Correct Order)

# 1. Fetch all remotes (parent + submodules)
# 2. Pull parent repository
# 3. Update submodules to tracked commits
git-submodule-manager workflow-pull

Pushing Changes (Correct Order)

# 1. Stage changes in all repos
# 2. Commit submodule changes
# 3. Push submodules
# 4. Stage submodule reference updates
# 5. Commit parent
# 6. Push parent
git-submodule-manager workflow-push -m "Your commit message"

Update

task git-submodule-manager:update

Or manually:

cd ~/.taskfiles/taskscripts/git-submodule-manager && git fetch origin && git reset --hard origin/HEAD

Uninstall

task git-submodule-manager:remove

Or manually:

rm -rf ~/.taskfiles/taskscripts/git-submodule-manager
rm -f ~/.local/bin/git-submodule-manager
rm -f ~/.taskfiles/Taskfile.git-submodule-manager.yml

Requirements

  • Python 3.10+
  • git
  • uv (optional, will be used if available)

Files in this Gist

File Description
git_submodule_manager.py The main script — PEP 723 uv inline
install.sh One-liner installer
Taskfile.git-submodule-manager.yml Lifecycle tasks (install/update/remove/status)
CHANGELOG.md Version history
README.md This file

License

MIT — Feel free to use, modify, and distribute.

Contributing

This is a Gist — contributions via comments or forks welcome!

name git_submodule_manager_dev
description Expert agent for developing and maintaining the git-submodule-manager tool.
applyTo **
priority high

git-submodule-manager — Development Agent

You are an expert developer for the git-submodule-manager project: a comprehensive git submodule management tool written as a PEP 723 uv inline script.

Persona

  • You specialize in Python script development, git automation, and terminal UI design
  • You understand the PEP 723 inline-script metadata format and the uv runner
  • Your primary responsibility: extend and maintain git_submodule_manager.py while keeping it backward compatible

Project Knowledge

Tech Stack

  • Python ≥ 3.10 (PEP 723 uv inline script — #!/usr/bin/env -S uv run)
  • Typer (CLI framework)
  • Rich (terminal output with colors and tables)
  • Git command-line interface

File Structure

File Purpose Update when…
git_submodule_manager.py Main script — all functionality Adding/changing any feature
README.md User documentation Any user-visible change
CHANGELOG.md Version history Every commit
install.sh One-liner installer Changes to install paths
Taskfile.git-submodule-manager.yml Lifecycle tasks New lifecycle tasks

Backward Compatibility — CRITICAL

ALWAYS preserve backward compatibility. This script is installed globally.

  • Never remove existing commands or options
  • Never change default output format in a breaking way
  • Never add required configuration — all new options must have sensible defaults
  • Keep dependencies minimal in the PEP 723 header

Development Checklist

When modifying code:

  • Update CHANGELOG.md under [Unreleased]
  • Test with both regular repos and submodule repos
  • Test JSON output (--json)
  • Test with and without submodules
  • Verify error handling works correctly
  • Run the script to verify no syntax errors
  • If adding commands, update README examples
  • Update version string if making a release

Commands

Main Commands

  • check - Comprehensive status check
  • quick - Fast dirty-check only
  • list-submodules - List all submodules
  • workflow-pull - Automated pull workflow
  • workflow-push - Automated push workflow

Options

  • --verbose, -v - Show detailed information
  • --json, -j - Output as JSON
  • --no-recommendations - Skip recommendations

Code Patterns

Adding a New Command

@app.command()
def my_new_command(
    path: Optional[Path] = typer.Argument(None, ...),
    my_option: bool = typer.Option(False, "--my-option", ...),
):
    """Clear one-line description for help text."""
    repo_path = path or Path.cwd()
    # Implementation here

Running Git Commands

Always use the run_git_command helper:

code, stdout, stderr = run_git_command(
    ["status", "--porcelain"],
    repo_path
)
if code != 0:
    # Handle error

Rich Output

Use Rich components for nice output:

from rich.panel import Panel
from rich.table import Table

console.print(Panel.fit("[bold]Title[/bold]"))

table = Table(show_header=True)
table.add_column("Name", style="cyan")
table.add_row("value")
console.print(table)

Testing

Test manually with:

# Regular repo without submodules
./git_submodule_manager.py check /path/to/simple/repo

# Repo with submodules
./git_submodule_manager.py check /path/to/monorepo

# JSON output
./git_submodule_manager.py check --json

# Quick mode
./git_submodule_manager.py quick

# Workflows
./git_submodule_manager.py workflow-pull
./git_submodule_manager.py workflow-push -m "test commit"

Release Process

  1. Update version in script if applicable
  2. Update CHANGELOG.md with version and date
  3. Commit all changes
  4. Test installation: cat install.sh | bash
  5. Push to Gist: git push
  6. Create git tag: git tag -a v1.0.0 -m "Release v1.0.0"
  7. Push tag: git push origin v1.0.0

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[1.0.0] - 2026-04-17

Added

  • Initial Release: Comprehensive git submodule management tool
    • check command for detailed repository and submodule status
    • quick command for fast dirty-check
    • list-submodules command to list all submodules
    • workflow-pull command for automated pull workflow
    • workflow-push command for automated push workflow
    • Rich console output with colors and tables
    • JSON output support for automation
    • Actionable recommendations based on repository state
    • PEP 723 uv inline script format (zero external dependencies)
    • Full install.sh installer
    • Taskfile for lifecycle management
    • Complete documentation

Features

  • Status checking for parent and all submodules
  • Submodule status prefix interpretation (+, -, U, space)
  • Detection of detached HEAD state
  • Ahead/behind commit counting
  • Modified, staged, and untracked file tracking
  • Unpushed commit detection
  • Support for both regular repos and submodules

How to Update This Changelog

When making changes, add entries under the [Unreleased] section following these categories:

  • Added - New features
  • Changed - Changes to existing functionality
  • Deprecated - Soon-to-be removed features
  • Removed - Removed features
  • Fixed - Bug fixes
  • Security - Security improvements

Before releasing a new version:

  1. Create a new version header (e.g., ## [1.0.0] - YYYY-MM-DD)
  2. Move all unreleased changes under that version
  3. Add a new empty [Unreleased] section at the top
  4. Create a git tag: git tag -a v1.0.0 -m "Release version 1.0.0"
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "rich>=13.0.0",
# "typer>=0.12.0",
# ]
# ///
"""
git-submodule-manager — Comprehensive Git Submodule Management Tool
A standalone Python script (PEP 723 uv inline, minimal external deps) that
provides comprehensive git submodule status checking, workflow automation,
and actionable recommendations.
Run directly or install globally for easy access from any repository.
Usage:
./git_submodule_manager.py # Check current directory
./git_submodule_manager.py /path/to/repo # Check specific repository
./git_submodule_manager.py --verbose # Detailed output
./git_submodule_manager.py --json # JSON output for automation
Install:
curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/f859836fb27910e328cc653f45838354/raw/install.sh | bash
For more information: https://gist.github.com/tobiashochguertel/f859836fb27910e328cc653f45838354
"""
import json
import os
import subprocess
import sys
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.tree import Tree
console = Console()
app = typer.Typer(
help="Git Submodule Manager - Comprehensive status checking and workflow automation"
)
class RepoStatus(Enum):
"""Repository status codes."""
CLEAN = "clean"
MODIFIED = "modified"
UNTRACKED = "untracked"
AHEAD = "ahead"
BEHIND = "behind"
DIVERGED = "diverged"
DETACHED = "detached"
ERROR = "error"
@dataclass
class SubmoduleInfo:
"""Information about a git submodule."""
name: str
path: str
commit_hash: str
status_prefix: str # +, -, ' ', U
branch: Optional[str] = None
has_changes: bool = False
unpushed_commits: bool = False
is_detached: bool = False
@dataclass
class RepoInfo:
"""Information about a git repository."""
path: Path
name: str
is_submodule: bool = False
current_branch: Optional[str] = None
status: RepoStatus = RepoStatus.CLEAN
is_detached: bool = False
modified_files: list = field(default_factory=list)
untracked_files: list = field(default_factory=list)
staged_files: list = field(default_factory=list)
ahead_count: int = 0
behind_count: int = 0
submodules: list = field(default_factory=list)
error_message: Optional[str] = None
def run_git_command(
cmd: list[str], cwd: Path, check: bool = False
) -> tuple[int, str, str]:
"""Run a git command and return (returncode, stdout, stderr)."""
try:
result = subprocess.run(
["git"] + cmd, cwd=cwd, capture_output=True, text=True, check=check
)
return result.returncode, result.stdout, result.stderr
except Exception as e:
return 1, "", str(e)
def get_current_branch(repo_path: Path) -> Optional[str]:
"""Get the current branch name or None if detached."""
code, stdout, _ = run_git_command(["branch", "--show-current"], repo_path)
if code == 0 and stdout.strip():
return stdout.strip()
return None
def check_if_detached(repo_path: Path) -> bool:
"""Check if the repository is in detached HEAD state."""
code, stdout, _ = run_git_command(["symbolic-ref", "--short", "HEAD"], repo_path)
return code != 0
def get_commit_counts(repo_path: Path) -> tuple[int, int]:
"""Get ahead and behind commit counts relative to upstream."""
code, stdout, _ = run_git_command(
["rev-list", "--left-right", "--count", "HEAD...@{u}"], repo_path
)
if code == 0:
parts = stdout.strip().split()
if len(parts) == 2:
return int(parts[0]), int(parts[1])
return 0, 0
def get_status_files(repo_path: Path) -> tuple[list, list, list]:
"""Get modified, untracked, and staged files."""
modified = []
untracked = []
staged = []
# Get porcelain status
code, stdout, _ = run_git_command(["status", "--porcelain"], repo_path)
if code == 0:
for line in stdout.strip().split("\n"):
if not line:
continue
status_code = line[:2]
filepath = line[3:]
if status_code[0] in "MADRC": # Staged
staged.append(filepath)
if status_code[1] in "MD": # Modified
modified.append(filepath)
if status_code == "??": # Untracked
untracked.append(filepath)
return modified, untracked, staged
def has_unpushed_commits(repo_path: Path) -> bool:
"""Check if there are unpushed commits."""
code, stdout, _ = run_git_command(["log", "@{u}..HEAD", "--oneline"], repo_path)
return code == 0 and bool(stdout.strip())
def get_submodules(repo_path: Path) -> list[SubmoduleInfo]:
"""Get list of submodules with their status."""
submodules = []
code, stdout, _ = run_git_command(["submodule", "status"], repo_path)
if code != 0 or not stdout.strip():
return submodules
for line in stdout.strip().split("\n"):
if not line:
continue
# Parse: [+-U ]<commit> <path> (<branch>)
prefix = line[0] if line[0] in "+-U " else ""
if prefix in "+-U ":
line = line[1:]
parts = line.split(maxsplit=2)
if len(parts) >= 2:
commit = parts[0]
path = parts[1]
name = parts[2].strip("()") if len(parts) > 2 else path
sub_path = repo_path / path
sub_info = SubmoduleInfo(
name=name,
path=path,
commit_hash=commit[:12],
status_prefix=prefix,
branch=get_current_branch(sub_path),
has_changes=bool(prefix == "+"),
is_detached=check_if_detached(sub_path),
unpushed_commits=has_unpushed_commits(sub_path),
)
submodules.append(sub_info)
return submodules
def analyze_repository(repo_path: Path, is_submodule: bool = False) -> RepoInfo:
"""Analyze a git repository and return its status."""
repo_info = RepoInfo(path=repo_path, name=repo_path.name, is_submodule=is_submodule)
# Check if it's actually a git repo
git_dir = repo_path / ".git"
if not git_dir.exists():
# Might be a submodule with .git as file pointing elsewhere
git_file = repo_path / ".git"
if not git_file.exists():
repo_info.status = RepoStatus.ERROR
repo_info.error_message = "Not a git repository"
return repo_info
# Get branch info
repo_info.current_branch = get_current_branch(repo_path)
repo_info.is_detached = check_if_detached(repo_path)
# Get file status
modified, untracked, staged = get_status_files(repo_path)
repo_info.modified_files = modified
repo_info.untracked_files = untracked
repo_info.staged_files = staged
# Get commit counts
repo_info.ahead_count, repo_info.behind_count = get_commit_counts(repo_path)
# Determine overall status
if repo_info.error_message:
repo_info.status = RepoStatus.ERROR
elif repo_info.is_detached:
repo_info.status = RepoStatus.DETACHED
elif repo_info.ahead_count > 0 and repo_info.behind_count > 0:
repo_info.status = RepoStatus.DIVERGED
elif repo_info.ahead_count > 0:
repo_info.status = RepoStatus.AHEAD
elif repo_info.behind_count > 0:
repo_info.status = RepoStatus.BEHIND
elif modified or staged:
repo_info.status = RepoStatus.MODIFIED
elif untracked:
repo_info.status = RepoStatus.UNTRACKED
else:
repo_info.status = RepoStatus.CLEAN
# Get submodules
if not is_submodule: # Don't recursively check submodules of submodules
repo_info.submodules = get_submodules(repo_path)
return repo_info
def format_status(status: RepoStatus) -> str:
"""Format status with color codes."""
status_colors = {
RepoStatus.CLEAN: "[green]✓ Clean[/green]",
RepoStatus.MODIFIED: "[yellow]✏ Modified[/yellow]",
RepoStatus.UNTRACKED: "[blue]? Untracked files[/blue]",
RepoStatus.AHEAD: "[cyan]↑ Ahead[/cyan]",
RepoStatus.BEHIND: "[orange]↓ Behind[/orange]",
RepoStatus.DIVERGED: "[red]⇵ Diverged[/red]",
RepoStatus.DETACHED: "[magenta]⚠ Detached HEAD[/magenta]",
RepoStatus.ERROR: "[red]✗ Error[/red]",
}
return status_colors.get(status, str(status))
def format_submodule_prefix(prefix: str) -> str:
"""Format submodule status prefix."""
prefix_map = {
"+": "[yellow]+[/yellow] Modified",
"-": "[red]-[/red] Not initialized",
"U": "[red]U[/red] Merge conflict",
" ": "[green] [/green] Current",
}
return prefix_map.get(prefix, prefix)
def print_repo_status(repo_info: RepoInfo, verbose: bool = False):
"""Print repository status in a nice format."""
# Main info table
table = Table(title=f"Repository: {repo_info.name}", show_header=True)
table.add_column("Property", style="cyan")
table.add_column("Value", style="white")
table.add_row("Path", str(repo_info.path))
table.add_row(
"Branch", repo_info.current_branch or "[magenta]Detached HEAD[/magenta]"
)
table.add_row("Status", format_status(repo_info.status))
if repo_info.ahead_count > 0:
table.add_row("Ahead by", f"{repo_info.ahead_count} commit(s)")
if repo_info.behind_count > 0:
table.add_row("Behind by", f"{repo_info.behind_count} commit(s)")
table.add_row("Modified files", str(len(repo_info.modified_files)))
table.add_row("Staged files", str(len(repo_info.staged_files)))
table.add_row("Untracked files", str(len(repo_info.untracked_files)))
if repo_info.error_message:
table.add_row("Error", f"[red]{repo_info.error_message}[/red]")
console.print(table)
# Detailed file lists
if verbose:
if repo_info.modified_files:
console.print("\n[yellow]Modified files:[/yellow]")
for f in repo_info.modified_files:
console.print(f" • {f}")
if repo_info.staged_files:
console.print("\n[green]Staged files:[/green]")
for f in repo_info.staged_files:
console.print(f" • {f}")
if repo_info.untracked_files:
console.print("\n[blue]Untracked files:[/blue]")
for f in repo_info.untracked_files:
console.print(f" • {f}")
def print_submodules(submodules: list[SubmoduleInfo], verbose: bool = False):
"""Print submodule information."""
if not submodules:
console.print("\n[dim]No submodules configured[/dim]")
return
console.print(f"\n[bold]Submodules ({len(submodules)}):[/bold]")
table = Table(show_header=True)
table.add_column("Name", style="cyan")
table.add_column("Path", style="dim")
table.add_column("Commit", style="white")
table.add_column("Status", style="yellow")
table.add_column("Branch", style="green")
for sub in submodules:
status = format_submodule_prefix(sub.status_prefix)
if sub.is_detached:
status += " [magenta](detached)[/magenta]"
if sub.unpushed_commits:
status += " [cyan](unpushed)[/cyan]"
branch = sub.branch or "[magenta]detached[/magenta]"
table.add_row(sub.name, sub.path, sub.commit_hash, status, branch)
console.print(table)
def print_recommendations(repo_info: RepoInfo):
"""Print actionable recommendations based on repository status."""
recommendations = []
if repo_info.status == RepoStatus.ERROR:
recommendations.append(("error", "Fix the repository error before proceeding"))
if repo_info.status == RepoStatus.DETACHED:
recommendations.append(("warning", "Repository is in detached HEAD state"))
recommendations.append(("info", "Create a branch: git checkout -b my-branch"))
if repo_info.modified_files:
recommendations.append(("info", f"Stage changes: git add ."))
recommendations.append(
("info", f"Commit changes: git commit -m 'your message'")
)
if repo_info.ahead_count > 0:
recommendations.append(
("info", f"Push changes: git push origin {repo_info.current_branch}")
)
if repo_info.behind_count > 0:
recommendations.append(
("info", f"Pull latest: git pull origin {repo_info.current_branch}")
)
if repo_info.untracked_files:
recommendations.append(("info", f"Add untracked files or update .gitignore"))
# Submodule recommendations
for sub in repo_info.submodules:
if sub.status_prefix == "-":
recommendations.append(
("warning", f"Initialize {sub.name}: git submodule update --init")
)
if sub.status_prefix == "+":
recommendations.append(
("info", f"Stage {sub.name} update: git add {sub.path}")
)
if sub.is_detached:
recommendations.append(
("warning", f"{sub.name} is detached - create a branch to make changes")
)
if recommendations:
console.print("\n[bold]Recommendations:[/bold]")
for level, msg in recommendations:
color = {"error": "red", "warning": "yellow", "info": "blue"}.get(
level, "white"
)
console.print(f" [{color}]• {msg}[/{color}]")
def output_json(repo_info: RepoInfo):
"""Output repository status as JSON."""
data = {
"name": repo_info.name,
"path": str(repo_info.path),
"is_submodule": repo_info.is_submodule,
"branch": repo_info.current_branch,
"status": repo_info.status.value,
"is_detached": repo_info.is_detached,
"files": {
"modified": repo_info.modified_files,
"staged": repo_info.staged_files,
"untracked": repo_info.untracked_files,
},
"commits": {
"ahead": repo_info.ahead_count,
"behind": repo_info.behind_count,
},
"submodules": [
{
"name": sub.name,
"path": sub.path,
"commit": sub.commit_hash,
"status_prefix": sub.status_prefix,
"branch": sub.branch,
"is_detached": sub.is_detached,
"has_changes": sub.has_changes,
"unpushed_commits": sub.unpushed_commits,
}
for sub in repo_info.submodules
],
}
print(json.dumps(data, indent=2))
@app.command()
def check(
path: Optional[Path] = typer.Argument(
None,
help="Path to repository (default: current directory)",
exists=True,
file_okay=False,
dir_okay=True,
),
verbose: bool = typer.Option(
False, "--verbose", "-v", help="Show detailed information"
),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
no_recommendations: bool = typer.Option(
False, "--no-recommendations", help="Skip recommendations"
),
):
"""Check git status of a repository and its submodules."""
repo_path = path or Path.cwd()
# Analyze repository
repo_info = analyze_repository(repo_path)
# Output
if json_output:
output_json(repo_info)
return
# Print header
console.print(
Panel.fit(
f"[bold blue]Git Submodule Manager[/bold blue]\n"
f"[dim]{repo_info.name}[/dim]",
title="Status Check",
border_style="blue",
)
)
# Print status
print_repo_status(repo_info, verbose)
print_submodules(repo_info.submodules, verbose)
if not no_recommendations:
print_recommendations(repo_info)
# Summary
console.print(
f"\n[dim]Checked {1 + len(repo_info.submodules)} repository(ies)[/dim]"
)
@app.command()
def list_submodules(
path: Optional[Path] = typer.Argument(
None,
help="Path to repository (default: current directory)",
exists=True,
file_okay=False,
dir_okay=True,
),
):
"""List all submodules in the repository."""
repo_path = path or Path.cwd()
submodules = get_submodules(repo_path)
if not submodules:
console.print("[yellow]No submodules found[/yellow]")
return
console.print(f"[bold]Found {len(submodules)} submodule(s):[/bold]\n")
for sub in submodules:
console.print(f" [cyan]{sub.name}[/cyan]")
console.print(f" Path: {sub.path}")
console.print(f" Commit: {sub.commit_hash}")
console.print(f" Status: {format_submodule_prefix(sub.status_prefix)}")
if sub.branch:
console.print(f" Branch: {sub.branch}")
console.print()
@app.command()
def quick(
path: Optional[Path] = typer.Argument(
None,
help="Path to repository (default: current directory)",
),
):
"""Quick status check - shows only dirty repositories."""
repo_path = path or Path.cwd()
repo_info = analyze_repository(repo_path)
dirty = []
if repo_info.status != RepoStatus.CLEAN:
dirty.append((repo_info.name, repo_info.status.value))
for sub in repo_info.submodules:
if sub.status_prefix == "+" or sub.unpushed_commits or sub.is_detached:
dirty.append(
(sub.name, "modified" if sub.status_prefix == "+" else "unpushed")
)
if dirty:
console.print("[yellow]Dirty repositories:[/yellow]")
for name, status in dirty:
console.print(f" • {name}: {status}")
else:
console.print("[green]All repositories are clean ✓[/green]")
@app.command()
def workflow_pull(
path: Optional[Path] = typer.Argument(
None,
help="Path to repository (default: current directory)",
),
):
"""Execute the recommended pull workflow: fetch all, pull parent, update submodules."""
repo_path = path or Path.cwd()
console.print(
Panel.fit("[bold blue]Git Pull Workflow[/bold blue]", border_style="blue")
)
# Step 1: Fetch all remotes
console.print("\n[bold]Step 1:[/bold] Fetching all remotes...")
code, _, stderr = run_git_command(["fetch", "--all"], repo_path)
if code != 0:
console.print(f"[red]✗ Failed to fetch: {stderr}[/red]")
return
for sub in get_submodules(repo_path):
sub_path = repo_path / sub.path
run_git_command(["fetch", "--all"], sub_path)
console.print("[green]✓ Fetch complete[/green]")
# Step 2: Pull parent
console.print("\n[bold]Step 2:[/bold] Pulling parent repository...")
branch = get_current_branch(repo_path) or "main"
code, _, stderr = run_git_command(["pull", "origin", branch], repo_path)
if code != 0:
console.print(f"[red]✗ Failed to pull: {stderr}[/red]")
return
console.print("[green]✓ Parent repository updated[/green]")
# Step 3: Update submodules
console.print("\n[bold]Step 3:[/bold] Updating submodules...")
code, _, stderr = run_git_command(
["submodule", "update", "--init", "--recursive"], repo_path
)
if code != 0:
console.print(f"[red]✗ Failed to update submodules: {stderr}[/red]")
return
console.print("[green]✓ Submodules updated[/green]")
console.print("\n[bold green]✓ Pull workflow complete![/bold green]")
@app.command()
def workflow_push(
path: Optional[Path] = typer.Argument(
None,
help="Path to repository (default: current directory)",
),
message: Optional[str] = typer.Option(
None,
"--message",
"-m",
help="Commit message (optional, will prompt if not provided)",
),
):
"""Execute the recommended push workflow: commit submodules first, then parent."""
repo_path = path or Path.cwd()
console.print(
Panel.fit("[bold blue]Git Push Workflow[/bold blue]", border_style="blue")
)
# Get commit message if not provided
commit_msg = message
if not commit_msg:
console.print("\n[yellow]Please provide a commit message:[/yellow]")
commit_msg = input("> ").strip()
if not commit_msg:
console.print("[red]✗ Commit message is required[/red]")
raise typer.Exit(1)
# Step 1: Stage changes
console.print("\n[bold]Step 1:[/bold] Staging changes...")
for sub in get_submodules(repo_path):
sub_path = repo_path / sub.path
run_git_command(["add", "-A"], sub_path)
run_git_command(["add", "-A"], repo_path)
console.print("[green]✓ Changes staged[/green]")
# Step 2: Commit submodule changes
console.print("\n[bold]Step 2:[/bold] Committing submodule changes...")
for sub in get_submodules(repo_path):
sub_path = repo_path / sub.path
# Check if there are changes to commit
code, stdout, _ = run_git_command(["status", "--porcelain"], sub_path)
if stdout.strip():
run_git_command(["commit", "-m", f"Update {sub.name}"], sub_path)
console.print(f" [green]✓ Committed {sub.name}[/green]")
# Step 3: Push submodules
console.print("\n[bold]Step 3:[/bold] Pushing submodule changes...")
for sub in get_submodules(repo_path):
sub_path = repo_path / sub.path
branch = get_current_branch(sub_path)
if branch:
code, _, _ = run_git_command(["push", "origin", branch], sub_path)
if code == 0:
console.print(f" [green]✓ Pushed {sub.name}[/green]")
# Step 4: Stage and commit parent
console.print("\n[bold]Step 4:[/bold] Committing parent repository...")
run_git_command(["add", "-A"], repo_path)
code, _, _ = run_git_command(["commit", "-m", commit_msg], repo_path)
if code == 0:
console.print("[green]✓ Parent repository committed[/green]")
# Step 5: Push parent
console.print("\n[bold]Step 5:[/bold] Pushing parent repository...")
branch = get_current_branch(repo_path) or "main"
code, _, _ = run_git_command(["push", "origin", branch], repo_path)
if code == 0:
console.print("[green]✓ Parent repository pushed[/green]")
console.print("\n[bold green]✓ Push workflow complete![/bold green]")
if __name__ == "__main__":
app()
#!/usr/bin/env bash
#
# git-submodule-manager Installer
# https://gist.github.com/tobiashochguertel/e07a0a79b3ce8e7d157405d6845e473e
#
# Usage:
# curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/e07a0a79b3ce8e7d157405d6845e473e/raw/install.sh | bash
#
# This script installs git-submodule-manager to ~/.taskfiles/taskscripts/git-submodule-manager/
# and sets up lifecycle management via Taskfile.
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Configuration
INSTALL_DIR="${HOME}/.taskfiles/taskscripts/git-submodule-manager"
GIST_URL="https://gist.github.com/tobiashochguertel/e07a0a79b3ce8e7d157405d6845e473e.git"
TASKFILES_DIR="${HOME}/.taskfiles"
TOOL_NAME="git-submodule-manager"
echo -e "${BLUE}📦 ${TOOL_NAME} Installer${NC}"
echo ""
# Check dependencies
echo -e "${BLUE}Checking dependencies...${NC}"
if ! command -v git &>/dev/null; then
echo -e "${RED}✗ git is required but not installed${NC}"
exit 1
fi
if ! command -v python3 &>/dev/null; then
echo -e "${RED}✗ python3 is required but not installed${NC}"
exit 1
fi
echo -e "${GREEN}✓ Dependencies satisfied${NC}"
echo ""
# Create directory structure
echo -e "${BLUE}Creating directory structure...${NC}"
mkdir -p "${INSTALL_DIR}"
mkdir -p "${TASKFILES_DIR}"
mkdir -p "${HOME}/.local/bin"
echo -e "${GREEN}✓ Directories created${NC}"
echo ""
# Clone or update the repository
if [ -d "${INSTALL_DIR}/.git" ]; then
echo -e "${BLUE}Updating existing installation...${NC}"
cd "${INSTALL_DIR}"
git fetch origin
git reset --hard origin/HEAD
echo -e "${GREEN}✓ Updated to latest version${NC}"
else
echo -e "${BLUE}Cloning ${TOOL_NAME}...${NC}"
git clone "${GIST_URL}" "${INSTALL_DIR}"
echo -e "${GREEN}✓ Cloned successfully${NC}"
fi
echo ""
# Make script executable
chmod +x "${INSTALL_DIR}/git_submodule_manager.py"
# Create symlink in a common PATH location
SYMLINK_DIR="${HOME}/.local/bin"
echo -e "${BLUE}Creating symlink in ${SYMLINK_DIR}...${NC}"
ln -sf "${INSTALL_DIR}/git_submodule_manager.py" "${SYMLINK_DIR}/git-submodule-manager"
echo -e "${GREEN}✓ Symlink created${NC}"
echo ""
# Check PATH
if [[ ":$PATH:" != *":${SYMLINK_DIR}:"* ]]; then
echo -e "${YELLOW}⚠ ${SYMLINK_DIR} is not in your PATH${NC}"
echo -e "${YELLOW} Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):${NC}"
echo -e " ${CYAN}export PATH=\"\$HOME/.local/bin:\$PATH\"${NC}"
echo ""
fi
# Deploy Taskfile for lifecycle management
echo -e "${BLUE}Deploying Taskfile for lifecycle management...${NC}"
cp "${INSTALL_DIR}/Taskfile.${TOOL_NAME}.yml" "${TASKFILES_DIR}/"
echo -e "${GREEN}✓ Taskfile deployed${NC}"
echo ""
# Function to add include to orchestrator
add_to_orchestrator() {
local ORCHESTRATOR="${TASKFILES_DIR}/Taskfile.taskscripts.yml"
local TOOL_KEY="${TOOL_NAME}"
local TOOL_TASKFILE="Taskfile.${TOOL_NAME}.yml"
if [ ! -f "${ORCHESTRATOR}" ]; then
echo -e "${BLUE}Creating taskscripts orchestrator...${NC}"
cat >"${ORCHESTRATOR}" <<EOF
# yaml-language-server: \$schema=https://taskfile.dev/schema.json
# Taskfile.taskscripts.yml — global taskscripts orchestrator
#
# Location: ~/.taskfiles/Taskfile.taskscripts.yml
# Purpose: Groups all per-tool Taskfiles from ~/.taskfiles/taskscripts/
# under a single include so the root Taskfile can flatten them.
#
# ─── How it works ────────────────────────────────────────────────────────────
# The root ~/.taskfiles/Taskfile.yml includes this file with flatten:true:
#
# includes:
# taskscripts:
# taskfile: Taskfile.taskscripts.yml
# optional: true
# flatten: true ← removes the "taskscripts:" prefix
# dir: ~/.taskfiles
#
# Each sub-include here retains its own namespace (e.g. ${TOOL_KEY}:), so the
# final task names are: task ${TOOL_KEY}:install, task ${TOOL_KEY}:update, etc.
# No triple-nesting (taskscripts:${TOOL_KEY}:install) thanks to flatten:true.
#
# ─── Adding more tools ───────────────────────────────────────────────────────
# When you install another gist/tool that ships a Taskfile.<tool>.yml, add it:
#
# my-tool:
# taskfile: taskscripts/Taskfile.my-tool.yml
# optional: true
#
version: "3"
includes:
# ── ${TOOL_NAME} — comprehensive git submodule management ─────────
# Exposes: ${TOOL_KEY}:install ${TOOL_KEY}:update
# ${TOOL_KEY}:remove ${TOOL_KEY}:status
${TOOL_KEY}:
taskfile: ${TOOL_TASKFILE}
optional: true
# ── add more tools here ───────────────────────────────────────────────────
# my-tool:
# taskfile: taskscripts/Taskfile.my-tool.yml
# optional: true
EOF
echo -e "${GREEN}✓ Orchestrator created with ${TOOL_NAME}${NC}"
else
# Check if already included
if grep -q "${TOOL_KEY}:" "${ORCHESTRATOR}"; then
echo -e "${GREEN}✓ ${TOOL_NAME} already in orchestrator${NC}"
else
echo -e "${BLUE}Adding ${TOOL_NAME} to orchestrator...${NC}"
# Create a temporary file with the new include added
awk -v tool="${TOOL_KEY}" -v taskfile="${TOOL_TASKFILE}" '
/^ # ── add more tools here/ {
print " # ── " tool " — comprehensive git submodule management ─────────"
print " # Exposes: " tool ":install " tool ":update"
print " # " tool ":remove " tool ":status"
print " " tool ":"
print " taskfile: " taskfile
print " optional: true"
print ""
}
{ print }
' "${ORCHESTRATOR}" >"${ORCHESTRATOR}.tmp" && mv "${ORCHESTRATOR}.tmp" "${ORCHESTRATOR}"
echo -e "${GREEN}✓ Added ${TOOL_NAME} to orchestrator${NC}"
fi
fi
}
# Add to orchestrator
add_to_orchestrator
echo ""
# Wire up in global Taskfile if it exists
echo -e "${BLUE}Checking global Taskfile...${NC}"
if [ -f "${TASKFILES_DIR}/Taskfile.yml" ]; then
if ! grep -q "Taskfile.taskscripts.yml" "${TASKFILES_DIR}/Taskfile.yml"; then
echo -e "${YELLOW}⚠ Your global Taskfile doesn't include the orchestrator${NC}"
echo -e "${YELLOW} Add this to ${TASKFILES_DIR}/Taskfile.yml:${NC}"
echo ""
cat <<'EOF'
includes:
taskscripts:
taskfile: Taskfile.taskscripts.yml
optional: true
flatten: true
dir: ~/.taskfiles
EOF
echo ""
else
echo -e "${GREEN}✓ Global Taskfile already wired${NC}"
fi
else
echo -e "${YELLOW}⚠ No global Taskfile found at ${TASKFILES_DIR}/Taskfile.yml${NC}"
echo -e "${YELLOW} Create one and include the orchestrator to enable:${NC}"
echo -e " task ${TOOL_NAME}:update"
echo -e " task ${TOOL_NAME}:remove"
echo ""
fi
# Installation summary
echo ""
echo -e "${GREEN}✅ Installation complete!${NC}"
echo ""
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${BLUE}📍 Installation Path:${NC}"
echo " ${INSTALL_DIR}"
echo ""
echo -e "${BLUE}🚀 Quick Start:${NC}"
echo " git-submodule-manager check # Check repository status"
echo " git-submodule-manager quick # Quick dirty check"
echo " git-submodule-manager workflow-pull # Pull workflow"
echo " git-submodule-manager workflow-push # Push workflow"
echo ""
echo -e "${BLUE}🔧 Task Commands:${NC}"
echo " task ${TOOL_NAME}:status # Show installation status"
echo " task ${TOOL_NAME}:update # Update to latest version"
echo " task ${TOOL_NAME}:remove # Uninstall"
echo ""
echo -e "${BLUE}📚 Documentation:${NC}"
echo " ${GIST_URL}"
echo ""
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
# Verify installation
echo -e "${BLUE}Verifying installation...${NC}"
if "${INSTALL_DIR}/git_submodule_manager.py" --help &>/dev/null; then
echo -e "${GREEN}✓ Installation verified${NC}"
else
echo -e "${RED}✗ Installation verification failed${NC}"
exit 1
fi
echo ""
version: '3'
# Taskfile for git-submodule-manager lifecycle management
# Deployed to ~/.taskfiles/Taskfile.git-submodule-manager.yml
vars:
GSUB_PROJECT_NAME: 'git-submodule-manager'
GSUB_INSTALL_DIR: '{{.HOME}}/.taskfiles/taskscripts/git-submodule-manager'
GSUB_SCRIPT: '{{.GSUB_INSTALL_DIR}}/git_submodule_manager.py'
GIST_URL: 'https://gist.github.com/tobiashochguertel/f859836fb27910e328cc653f45838354.git'
tasks:
default:
desc: Show git-submodule-manager tasks
cmds:
- echo "git-submodule-manager Lifecycle Tasks"
- echo ""
- echo " install - Install or reinstall git-submodule-manager"
- echo " update - Update to latest version from Gist"
- echo " remove - Uninstall git-submodule-manager"
- echo " status - Show installation status"
- echo " run - Run the script"
- echo ""
- echo "Usage:"
- echo " task git-submodule-manager:install"
- echo " task git-submodule-manager:update"
- echo " task git-submodule-manager:remove"
install:
desc: Install or reinstall git-submodule-manager
cmds:
- |
echo "Installing {{.GSUB_PROJECT_NAME}}..."
if [ -d "{{.GSUB_INSTALL_DIR}}" ]; then
echo "Already installed at {{.GSUB_INSTALL_DIR}}"
echo "Run 'task git-submodule-manager:update' to update"
else
curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/f859836fb27910e328cc653f45838354/raw/install.sh | bash
fi
update:
desc: Update to latest version from Gist
cmds:
- |
if [ ! -d "{{.GSUB_INSTALL_DIR}}/.git" ]; then
echo "Not installed. Run: task git-submodule-manager:install"
exit 1
fi
echo "Updating {{.GSUB_PROJECT_NAME}}..."
cd "{{.GSUB_INSTALL_DIR}}"
git fetch origin
git reset --hard origin/HEAD
echo "✓ Updated successfully"
echo ""
echo "Current version:"
git log --oneline -1
remove:
desc: Uninstall git-submodule-manager
cmds:
- |
echo "Removing {{.GSUB_PROJECT_NAME}}..."
# Remove install directory
if [ -d "{{.GSUB_INSTALL_DIR}}" ]; then
rm -rf "{{.GSUB_INSTALL_DIR}}"
echo "✓ Removed {{.GSUB_INSTALL_DIR}}"
fi
# Remove symlink
if [ -L "{{.HOME}}/.local/bin/git-submodule-manager" ]; then
rm -f "{{.HOME}}/.local/bin/git-submodule-manager"
echo "✓ Removed symlink"
fi
# Remove Taskfile
if [ -f "{{.HOME}}/.taskfiles/Taskfile.git-submodule-manager.yml" ]; then
rm -f "{{.HOME}}/.taskfiles/Taskfile.git-submodule-manager.yml"
echo "✓ Removed Taskfile"
fi
echo ""
echo "✓ git-submodule-manager has been uninstalled"
status:
desc: Show installation status
cmds:
- |
echo "git-submodule-manager Installation Status"
echo "=========================================="
echo ""
if [ -d "{{.GSUB_INSTALL_DIR}}/.git" ]; then
echo "Status: [green]Installed[/green]"
echo "Path: {{.GSUB_INSTALL_DIR}}"
echo ""
cd "{{.GSUB_INSTALL_DIR}}"
echo "Current commit:"
git log --oneline -1
echo ""
echo "Last update:"
git log --format="%cd" --date=relative -1
else
echo "Status: [red]Not installed[/red]"
echo ""
echo "Install with:"
echo " curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/f859836fb27910e328cc653f45838354/raw/install.sh | bash"
echo " or"
echo " task git-submodule-manager:install"
fi
run:
desc: Run git-submodule-manager
cmds:
- |
if [ -x "{{.GSUB_SCRIPT}}" ]; then
"{{.GSUB_SCRIPT}}"
else
echo "Not installed. Run: task git-submodule-manager:install"
exit 1
fi
check:
desc: Check repository status
cmds:
- |
if [ -x "{{.GSUB_SCRIPT}}" ]; then
"{{.GSUB_SCRIPT}}" check
else
echo "Not installed. Run: task git-submodule-manager:install"
exit 1
fi
quick:
desc: Quick status check (only dirty repos)
cmds:
- |
if [ -x "{{.GSUB_SCRIPT}}" ]; then
"{{.GSUB_SCRIPT}}" quick
else
echo "Not installed. Run: task git-submodule-manager:install"
exit 1
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment