git-status-all
No description available
Code
python
#!/usr/bin/env python3
"""
@title git-status-all
@description Check Git status for every repository under a root directory.
@author ropean, Claude Sonnet (Anthropic)
@version 1.0.0
Runs `git fetch --all` in parallel first (use --no-fetch to skip), then shows
status for all subdirectories — git repos and non-git dirs alike.
Output includes branch, ahead/behind remote, staged/unstaged/untracked counts,
and can be saved to a timestamped report file.
@example
python git-status-all.py
python git-status-all.py "D:\\Git"
python git-status-all.py "D:\\Git" --no-fetch
python git-status-all.py "D:\\Git" -o report.txt
"""
import argparse
import os
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
# ════════════════════════════════════════════════════════════
# USER CONFIG
# ════════════════════════════════════════════════════════════
PLATFORM_DEFAULTS = {
"win32": r"D:\Git",
"darwin": "~/Git",
"linux": "~/Git", # includes WSL
}
# ════════════════════════════════════════════════════════════
# END OF USER CONFIG
# ════════════════════════════════════════════════════════════
FETCH_WORKERS = 8
COL_NAME = 30
COL_BRANCH = 22
# ── Terminal colors ───────────────────────────────────────
def _supports_color() -> bool:
# TERM set and non-dumb covers Git Bash (isatty() can be False there)
term = os.environ.get("TERM", "")
if os.environ.get("FORCE_COLOR") or os.environ.get("COLORTERM"):
return True
if term and term != "dumb":
return True
if os.name == "nt" and (os.environ.get("WT_SESSION") or os.environ.get("TERM_PROGRAM")):
return True
return sys.stdout.isatty()
if _supports_color():
_C = {
"red": "\033[31m",
"yellow": "\033[33m",
"green": "\033[32m",
"cyan": "\033[36m",
"bold": "\033[1m",
"dim": "\033[2m",
"darkgray": "\033[90m",
"reset": "\033[0m",
}
else:
_C = {k: "" for k in ("red", "yellow", "green", "cyan", "bold", "dim", "darkgray", "reset")}
def c(color: str, text: str) -> str:
return f"{_C[color]}{text}{_C['reset']}"
# ── Path helpers ──────────────────────────────────────────
def resolve_root(arg: str | None) -> Path:
if arg:
p = Path(arg).expanduser()
else:
key = sys.platform if sys.platform in PLATFORM_DEFAULTS else "linux"
p = Path(PLATFORM_DEFAULTS[key]).expanduser()
# Skip resolve() for UNC paths (\\server\share) — it corrupts them on Windows
if not str(p).startswith("\\\\"):
p = p.resolve()
if not p.is_dir():
sys.exit(f"Error: directory not found: {p}")
return p
def scan_dirs(root: Path) -> tuple[list[Path], list[Path]]:
"""Return (git_repos, non_git_dirs) — both sorted by name, dotfiles excluded."""
repos, others = [], []
for entry in sorted(root.iterdir()):
if not entry.is_dir() or entry.name.startswith("."):
continue
(repos if (entry / ".git").exists() else others).append(entry)
return repos, others
# ── Git helpers ───────────────────────────────────────────
def _wsl_path(cwd: Path) -> tuple[str, str] | None:
"""If cwd is a WSL UNC path, return (distro, linux_path); else None."""
s = str(cwd)
for prefix in ("\\\\wsl.localhost\\", "\\\\wsl$\\"):
if s.startswith(prefix):
rest = s[len(prefix):] # "Ubuntu\home\robot-u\git\folio"
parts = rest.split("\\", 1)
distro = parts[0]
linux_path = ("/" + parts[1].replace("\\", "/")) if len(parts) > 1 else "/"
return distro, linux_path
return None
def _run(
cmd: list[str], cwd: Path, extra_env: dict[str, str] | None = None
) -> tuple[int, str, str]:
env = {**os.environ, **(extra_env or {})}
wsl = _wsl_path(cwd)
if wsl:
# Run git inside WSL so it uses Linux git semantics (no fileMode/CRLF/
# filename-encoding mismatches that Windows git introduces on WSL paths).
distro, linux_path = wsl
actual_cmd = ["wsl", "-d", distro, "git", "-C", linux_path] + cmd[1:]
result = subprocess.run(
actual_cmd, env=env,
capture_output=True, text=True, encoding="utf-8", errors="replace",
)
elif str(cwd).startswith("\\\\"):
# Generic UNC path — bypass dubious-ownership check
cmd = ["git", "-c", "safe.directory=*"] + cmd[1:]
result = subprocess.run(
cmd, cwd=cwd, env=env,
capture_output=True, text=True, encoding="utf-8", errors="replace",
)
else:
result = subprocess.run(
cmd, cwd=cwd, env=env,
capture_output=True, text=True, encoding="utf-8", errors="replace",
)
return result.returncode, result.stdout.strip(), result.stderr.strip()
def fetch_repo(repo: Path) -> tuple[Path, str]:
"""Fetch all remotes; return (repo, error_or_empty)."""
rc, _, err = _run(
["git", "fetch", "--all", "--quiet", "--no-progress"],
repo,
extra_env={"GIT_TERMINAL_PROMPT": "0"},
)
return repo, (err if rc != 0 else "")
def get_repo_status(repo: Path) -> dict:
_, branch, _ = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], repo)
_, porcelain, _ = _run(["git", "status", "--porcelain"], repo)
dirty_files = [ln for ln in porcelain.splitlines() if ln.strip()]
_, stash_out, _ = _run(["git", "stash", "list"], repo)
stash_count = len(stash_out.splitlines()) if stash_out else 0
ahead = behind = 0
_, upstream, _ = _run(
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
repo,
)
has_upstream = upstream and "fatal" not in upstream.lower() and "no upstream" not in upstream.lower()
if has_upstream:
_, ab_out, _ = _run(
["git", "rev-list", "--left-right", "--count", "HEAD...@{u}"],
repo,
)
parts = ab_out.split()
if len(parts) == 2:
try:
ahead, behind = int(parts[0]), int(parts[1])
except ValueError:
pass
else:
upstream = None
_, log_out, _ = _run(["git", "log", "-1", "--format=%h %s (%cr)"], repo)
return {
"repo": repo,
"branch": branch or "?",
"dirty_files": dirty_files,
"stash_count": stash_count,
"upstream": upstream,
"ahead": ahead,
"behind": behind,
"last_commit": log_out,
}
def row_color(info: dict) -> str:
if info["dirty_files"]:
return "red"
if info["ahead"] or info["behind"]:
return "yellow"
return "dim"
def status_label(info: dict) -> str:
parts = []
if info["dirty_files"]:
parts.append(f"{len(info['dirty_files'])} dirty")
if info["behind"]:
parts.append(f"{info['behind']} behind")
if info["ahead"]:
parts.append(f"{info['ahead']} ahead")
if info["stash_count"]:
parts.append(f"{info['stash_count']} stashed")
return ", ".join(parts) if parts else "clean"
# ── Display ───────────────────────────────────────────────
def print_repo_row(info: dict) -> None:
stash = f" [{info['stash_count']} stashed]" if info["stash_count"] else ""
label = status_label(info)
# Build plain text first so padding is correct, then colorize the whole row
row = f" {info['repo'].name:<{COL_NAME}} {info['branch']:<{COL_BRANCH}} {label}{stash}"
print(c(row_color(info), row))
def print_nongit_row(d: Path) -> None:
row = f" {d.name:<{COL_NAME}} {'(not git)':<{COL_BRANCH}} --"
print(c("darkgray", row))
# ── Report ────────────────────────────────────────────────
def save_report(results: list[dict], non_git: list[Path], root: Path, out: Path) -> None:
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
clean = [r for r in results if status_label(r) == "clean"]
unclean = [r for r in results if r not in clean]
md: list[str] = [
"# Git Status Report",
"",
f"- **Date**: {ts}",
f"- **Root**: `{root}`",
f"- **Git repos**: {len(results)} scanned — "
f"{len(clean)} clean, {len(unclean)} need attention",
f"- **Non-git dirs**: {len(non_git)}",
"",
f"## Needs Attention ({len(unclean)} repos)",
"",
]
if unclean:
for r in unclean:
md.append(f"### `{r['repo'].name}` — {status_label(r)}")
md.append(f"- Branch: `{r['branch']}`")
if r["upstream"]:
md.append(f"- Upstream: `{r['upstream']}` "
f"(ahead {r['ahead']}, behind {r['behind']})")
else:
md.append("- Upstream: none")
if r["dirty_files"]:
md.append(f"- Dirty files ({len(r['dirty_files'])}):")
for f in r["dirty_files"][:20]:
md.append(f" - `{f}`")
if len(r["dirty_files"]) > 20:
md.append(f" - ... and {len(r['dirty_files']) - 20} more")
if r["stash_count"]:
md.append(f"- Stashed entries: {r['stash_count']}")
if r["last_commit"]:
md.append(f"- Last commit: {r['last_commit']}")
md.append("")
else:
md.append("All repos are clean.\n")
md += [f"## Clean ({len(clean)} repos)", ""]
for r in clean:
up = f"(`{r['upstream']}`)" if r["upstream"] else "(no upstream)"
md.append(f"- `{r['repo'].name}` on `{r['branch']}` {up}")
md.append("")
if non_git:
md += [f"## Non-git directories ({len(non_git)})", ""]
for d in non_git:
md.append(f"- `{d.name}`")
md.append("")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(md), encoding="utf-8")
print(c("green", f"Report saved: {out}"))
# ── Entry point ───────────────────────────────────────────
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Check Git status for all directories under a root."
)
parser.add_argument(
"git_dir", nargs="?",
help="Root directory (default: platform-specific)",
)
parser.add_argument(
"--no-fetch", action="store_true",
help="Skip git fetch before checking status",
)
parser.add_argument(
"-o", "--output",
default=None, metavar="FILE",
help="Save a Markdown report to this file",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
root = resolve_root(args.git_dir)
print(c("bold", "\ngit-status-all"))
print(f"Scanning: {c('cyan', str(root))}\n")
repos, non_git = scan_dirs(root)
if not repos and not non_git:
sys.exit("No directories found.")
# ── Fetch ─────────────────────────────────────────────
fetch_errors: dict[Path, str] = {}
if repos and not args.no_fetch:
print(f"Fetching {len(repos)} repos", end="", flush=True)
with ThreadPoolExecutor(max_workers=FETCH_WORKERS) as pool:
futures = {pool.submit(fetch_repo, r): r for r in repos}
for future in as_completed(futures):
repo, err = future.result()
print(".", end="", flush=True)
if err:
fetch_errors[repo] = err
# '\r' + spaces clears any stray progress text git may have written
# directly to the console on Windows, then newline to move past it
sys.stdout.write("\r" + " " * 72 + "\r")
sys.stdout.flush()
print("done\n")
# ── Status + display ──────────────────────────────────
# Merge repos and non-git dirs into one sorted list for display
repo_map: dict[str, dict] = {}
for repo in repos:
repo_map[repo.name] = get_repo_status(repo)
non_git_names = {d.name for d in non_git}
all_names = sorted(repo_map.keys() | non_git_names, key=str.lower)
results: list[dict] = []
for name in all_names:
if name in repo_map:
info = repo_map[name]
results.append(info)
print_repo_row(info)
else:
print_nongit_row(root / name)
# ── Summary ───────────────────────────────────────────
clean = [r for r in results if status_label(r) == "clean"]
dirty = [r for r in results if r["dirty_files"]]
sync = [r for r in results if not r["dirty_files"] and (r["ahead"] or r["behind"])]
print(f"\n{'-' * 60}")
print(f" Repos: {len(results)} (+{len(non_git)} not git)")
print(c("dim", f" Clean: {len(clean)}"))
if sync:
print(c("yellow", f" Sync: {len(sync)}"))
if dirty:
print(c("red", f" Dirty: {len(dirty)}"))
if fetch_errors:
print(c("yellow", f"\n Fetch errors ({len(fetch_errors)}):"))
for repo, err in fetch_errors.items():
print(c("yellow", f" {repo.name}: {err[:80]}"))
if args.output:
save_report(results, non_git, root, Path(args.output))
if __name__ == "__main__":
main()File Information
- Filename:
git-status-all.py - Category: python
- Language: PYTHON