Skip to content

Instantly share code, notes, and snippets.

@justsh
Last active February 9, 2025 22:31
Show Gist options
  • Save justsh/4c28e3ea994405c656eb0d1ff9efdaf1 to your computer and use it in GitHub Desktop.
Save justsh/4c28e3ea994405c656eb0d1ff9efdaf1 to your computer and use it in GitHub Desktop.
Find git repositories recursively and output tables showing if their remotes and whether they have uncommitted changes
#!/bin/sh
# Execute an underlying
set -eu
prog="$(basename "$0")"
has_command() { command -v 1>/dev/null 2>&1; }
self="$0"
target="$(printf '%s' "$self" | tr '-' '_').py"
if has_command pipx; then
set -- pipx run "$target" "$@"
else
printf '%s: required dependency not found: %s' "$prog" "pipx" 1>&2
exit 2
fi
exec "$@"
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "rich"
# ]
# ///
"""
git-repos-status searches for git repositories recursively
and reports whether each found repo has uncommited changes and remotes.
"""
import argparse
import os
import os.path
import subprocess
from collections import defaultdict
from dataclasses import dataclass, replace
from enum import Enum
from getpass import getuser
from pathlib import Path
from typing import TypeVar
from rich.console import Console
from rich.markup import escape
from rich.table import Table
from rich.text import Text
from rich.tree import Tree
__authors__ = ["sys.exit0@gmail.com"]
__version__ = "202408.04"
@dataclass(frozen=True)
class GitRemoteURL:
"""A structured representation of a remote URL of configured for a git repository"""
class Type(Enum):
FETCH = "fetch"
PUSH = "push"
url: str
type: Type
@dataclass(frozen=True)
class GitRemote:
"""A structured representation of the output of `git remote -v`"""
name: str
urls: tuple[GitRemoteURL]
@classmethod
def from_string(cls, line):
name, url, rest = line.split(None, 2)
type = next(
e for k, e in GitRemoteURL.Type.__members__.items() if e.value in rest
)
return cls(name=name, urls=(GitRemoteURL(url=url, type=type),))
def _select_urls(self, url_type: GitRemoteURL.Type, all: bool = None, default=None):
urls = iter(u for u in self.urls if u.type == url_type)
if all:
return tuple(urls)
return next(urls, default=default)
@property
def push(self, all=None, default=None):
return self._select_urls(
url_type=GitRemoteURL.Type.PUSH, all=all, default=default
)
@property
def fetch(self, all=None, default=None):
return self._select_urls(
url_type=GitRemoteURL.Type.FETCH, all=all, default=default
)
def is_git_repo(repo_path: Path) -> bool:
"""Return whether the given path is a git repository"""
if (repo_path / ".git").exists():
return True
try:
subprocess.check_output(
["git", "-C", str(repo_path), "rev-parse", "--is-inside-work-tree"],
text=True,
stderr=subprocess.DEVNULL,
)
return True
except subprocess.CalledProcessError:
return False
def get_git_remotes(repo_path: Path, as_dict=None) -> list[GitRemote]:
"""Return a list of parsed git remotes for the given git repository"""
try:
output = subprocess.check_output(
["git", "-C", str(repo_path), "remote", "-v"],
text=True,
stderr=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
return []
remotes = {}
for line in output.splitlines():
r = GitRemote.from_string(line)
name = r.name
existing = remotes.get(name, None)
if existing is None:
remotes[name] = r
else:
remotes[name] = replace(existing, urls=existing.urls + r.urls)
if as_dict:
return remotes
return list(remotes.values())
def has_uncommitted_changes(repo_path: Path) -> bool:
"""Return whether the git repository has uncommitted changes"""
try:
output = subprocess.check_output(
["git", "-C", str(repo_path), "status", "--porcelain"], text=True
)
return bool(output.strip())
except subprocess.CalledProcessError:
return False
T = TypeVar("T", str, os.PathLike)
def unexpanduser(path: T) -> T:
"""Return a string or path-like object with the home variable collapsed to a tilde"""
home = os.path.expanduser("~")
s = str(path)
path_t = type(path)
if s.startswith(home):
home_dir = os.path.basename(home)
user = getuser()
tilde = "~" if home_dir == user else f"~{user}"
if os.path.basename(home) == getuser():
return path_t(tilde + s.removeprefix(home))
return path_t(s)
def build_git_repo_table(path, max_depth=None) -> Table:
"""Return a table of discovered git repositories under the given path"""
resolved = str(unexpanduser(path.resolve()))
table = Table(
title=f"Git Repositories: {resolved}",
show_lines=True,
expand=True,
# leading=1,
# row_styles=["", "dim"],
)
table.add_column("Path", style="cyan")
table.add_column("Dirty?", style="red")
table.add_column("Remotes", style="white")
def _format_git_remote(r):
t = Text("")
if not r.urls:
return t
t.append(f"{r.name}", style="blue")
t.append(":\n")
for u in r.urls:
t.append(" ")
t.append(f"{u.type.name:>5}", style="yellow")
t.append(" ")
t.append(f"{u.url}")
t.append("\n")
return t
for dirpath, dirs, files in os.walk(path):
if max_depth is not None and dirpath[len(resolved) :].count(os.sep) > max_depth:
del dirs[:]
continue
dirs_to_skip = []
for d in dirs:
repo_path = Path(dirpath) / d
if not is_git_repo(repo_path):
continue
remotes = get_git_remotes(repo_path)
has_changes = has_uncommitted_changes(repo_path)
field_path = Text(str(unexpanduser(repo_path.resolve())))
field_path.highlight_words([d], style="magenta")
field_remotes = Text.assemble(*[_format_git_remote(r) for r in remotes])
field_remotes.highlight_words([d + ".git", d], style="magenta")
table.add_row(
field_path,
Text("Yes", style="red") if has_changes else Text("No", style="green"),
field_remotes,
)
# If this directory is a git repo, stop further recursing its path.
dirs_to_skip.append(d)
for d in dirs_to_skip:
dirs.remove(d)
return table
def _expand_paths_one_level(paths):
"""Return a list of paths that have been resolved to at least one common prefix"""
if len(paths) == 1:
# If there is only one path (e.g., probably the default PWD),
# expand the list of directories as though the caller passed them separately
# so that separate tables are rendered for each.
# Rich does not offer progressive table rendering (they consider it "niche")
# so we print multiple tables to try to improve feedback from the script.
# See: https://github.com/Textualize/rich/issues/312
p = paths[0]
dirs = [f for f in p.iterdir() if f.is_dir()]
return dirs
# At this point, paths are still user provided and may or may not be resolved
# (i.e. expanded to absolute paths).
# Expand the paths, find their common prefix, and resolve them all
# relative to the common prefix.
resolved_paths = [p.resolve() for p in paths]
prefix = os.path.commonpath(resolved_paths)
if prefix:
# If a single common prefix was found,
# recalculate all paths relative to that prefix.
prefix = Path(prefix)
return [p.relative_to(prefix) for p in resolved_paths]
# If no common prefix was found,
# pass _each_ path to be expanded printed independently.
# To avoid infinite recursion
return [_expand_paths_one_level(p) for p in resolved_paths]
def _print_tree(paths, console=None):
"""Print a tree representing the collected parent paths to walk for git repositories"""
if not console:
console = Console()
if not paths:
raise ValueError("Cannot print tree for empty sequence of paths")
parents = defaultdict(list)
for p in paths:
parents[p.parent].append(p)
console.print(
"The following paths will be searched for git repositories:\n", style="green"
)
for parent, dirs in parents.items():
resolved = str(unexpanduser(parent.resolve()))
tree = Tree(
f":open_file_folder: [link file://{resolved}]{resolved}",
guide_style="white",
)
for d in dirs:
style = None
_branch = tree.add(
f"[bold blue]:open_file_folder: [link file://{d}]{escape(d.name)}",
style=style,
guide_style=style,
)
console.print(tree)
def print_git_repos(paths, max_depth=None) -> None:
"""Print a table of each discovered git repository"""
console = Console()
paths = _expand_paths_one_level(paths)
_print_tree(paths, console=console)
console.print("\n")
for p in paths:
resolved = str(unexpanduser(p.resolve()))
table = build_git_repo_table(p, max_depth=max_depth)
if table.rows:
console.print(table)
else:
t = Table(title=table.title, expand=True)
t.add_column("Path", style="cyan")
t.add_column("Message", style="red")
field_path = Text(resolved + os.path.sep + "*")
field_path.highlight_words([p.name], style="magenta")
t.add_row(field_path, "No git repositories found under path")
console.print(t)
def get_parser() -> argparse.ArgumentParser:
"""Return a command line parser for the main method"""
parser = argparse.ArgumentParser(description="Find and inspect Git repositories")
parser.add_argument(
"--max-depth",
type=int,
default=None,
help="Maximum recursion depth",
)
parser.add_argument(
"paths",
metavar="[path]",
type=Path,
nargs="*",
default=[Path(".")],
help="Root directory to start searching for Git repositories",
)
return parser
def main() -> None:
parser = get_parser()
args = parser.parse_args()
paths = args.paths
max_depth = args.max_depth
print_git_repos(paths, max_depth=max_depth)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment