Last active
February 9, 2025 22:31
-
-
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
This file contains 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
#!/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 "$@" |
This file contains 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.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