#!/usr/bin/env python3 """本機 Git remote 只讀盤點工具。 此工具掃描指定 root 底下可見的 Git working tree,讀取 `.git/config` 中的 remote URL,並在輸出前移除 URL 內的帳密。它不會 fetch、push、 修改 remote,也不會連線到 GitHub 或 Gitea。 """ from __future__ import annotations import argparse import configparser import json import os import sys from pathlib import Path from urllib.parse import urlsplit, urlunsplit DEFAULT_EXCLUDE_NAMES = { ".cache", ".cargo", ".claude", ".codex", ".gemini", ".git", ".gradle", ".npm", ".nvm", ".openclaw", ".pyenv", ".rustup", ".Trash", ".venv", "__pycache__", "Applications", "Applications (Parallels)", "Caches", "DerivedData", "Library", "Movies", "Music", "node_modules", "Parallels", "Pictures", "venv", } def redact_url(value: str) -> str: if "://" not in value: if "@" in value and ":" in value.split("@", 1)[1]: return value.split("@", 1)[1] return value parts = urlsplit(value) netloc = parts.netloc.split("@", 1)[-1] return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) def repo_slug_from_url(value: str) -> str: redacted = redact_url(value).removesuffix("/") if "://" in redacted: path = urlsplit(redacted).path.strip("/") elif ":" in redacted: path = redacted.split(":", 1)[1].strip("/") else: path = redacted.strip("/") return path.removesuffix(".git") def classify_remote(url: str, gitea_fragment: str) -> str: lowered = url.lower() if gitea_fragment.lower() in lowered: return "gitea" if "github.com" in lowered: return "github" if "192.168.0.110:8929" in lowered: return "gitlab_110" if "192.168.0.110" in lowered: return "internal_git_110" return "other" def git_config_path(repo_path: Path) -> Path | None: git_path = repo_path / ".git" if git_path.is_dir(): config_path = git_path / "config" return config_path if config_path.exists() else None if not git_path.is_file(): return None text = git_path.read_text(encoding="utf-8", errors="replace") for line in text.splitlines(): if line.startswith("gitdir:"): raw_gitdir = line.split(":", 1)[1].strip() gitdir = Path(raw_gitdir) if not gitdir.is_absolute(): gitdir = (repo_path / gitdir).resolve() config_path = gitdir / "config" return config_path if config_path.exists() else None return None def remote_name(section: str) -> str | None: prefix = 'remote "' if section.startswith(prefix) and section.endswith('"'): return section[len(prefix) : -1] return None def read_remotes(repo_path: Path, gitea_fragment: str) -> list[dict[str, str]]: config_path = git_config_path(repo_path) if config_path is None: return [] parser = configparser.RawConfigParser(strict=False) parser.read(config_path, encoding="utf-8") remotes: list[dict[str, str]] = [] for section in parser.sections(): name = remote_name(section) if not name or not parser.has_option(section, "url"): continue raw_url = parser.get(section, "url").strip() redacted_url = redact_url(raw_url) remotes.append( { "name": name, "kind": classify_remote(redacted_url, gitea_fragment), "url_redacted": redacted_url, "repo_slug": repo_slug_from_url(redacted_url), } ) return remotes def should_skip_dir(path: Path, root: Path, max_depth: int, exclude_names: set[str]) -> bool: if path.name in exclude_names: return True try: depth = len(path.relative_to(root).parts) except ValueError: return True return depth > max_depth def find_repos(roots: list[Path], max_depth: int, exclude_names: set[str]) -> list[Path]: repos: dict[str, Path] = {} for root in roots: if not root.exists(): continue for current, dirs, _files in os.walk(root): current_path = Path(current) if should_skip_dir(current_path, root, max_depth, exclude_names): dirs[:] = [] continue if (current_path / ".git").exists(): repos[str(current_path.resolve())] = current_path.resolve() dirs[:] = [] continue dirs[:] = [ name for name in dirs if not should_skip_dir(current_path / name, root, max_depth, exclude_names) ] return sorted(repos.values(), key=lambda path: str(path)) def summarize_repo(repo_path: Path, remotes: list[dict[str, str]]) -> dict[str, object]: gitea = [remote["repo_slug"] for remote in remotes if remote["kind"] == "gitea"] github = [remote["repo_slug"] for remote in remotes if remote["kind"] == "github"] internal_110 = [ remote["repo_slug"] for remote in remotes if remote["kind"] in ("internal_git_110", "gitlab_110") ] if gitea and github: status = "mapped" elif gitea: status = "gitea_only_local" elif github: status = "github_only_local" elif internal_110: status = "internal_110_only" else: status = "other_remote" return { "repo_path": str(repo_path), "repo_name": repo_path.name, "status": status, "gitea_repos": sorted(set(gitea)), "github_repos": sorted(set(github)), "internal_110_repos": sorted(set(internal_110)), "remotes": remotes, } def build_inventory( roots: list[Path], max_depth: int, exclude_names: set[str], gitea_fragment: str, ) -> dict[str, object]: repo_paths = find_repos(roots, max_depth, exclude_names) repos = [ summarize_repo(repo_path, read_remotes(repo_path, gitea_fragment)) for repo_path in repo_paths ] gitea_linked = [repo for repo in repos if repo["gitea_repos"]] github_linked = [repo for repo in repos if repo["github_repos"]] mapped = [repo for repo in repos if repo["status"] == "mapped"] gitea_only = [repo for repo in repos if repo["status"] == "gitea_only_local"] github_only = [repo for repo in repos if repo["status"] == "github_only_local"] internal_110 = [repo for repo in repos if repo["status"] == "internal_110_only"] unique_gitea = sorted( { item for repo in repos for item in repo.get("gitea_repos", []) if isinstance(item, str) } ) unique_github = sorted( { item for repo in repos for item in repo.get("github_repos", []) if isinstance(item, str) } ) unique_internal_110 = sorted( { item for repo in repos for item in repo.get("internal_110_repos", []) if isinstance(item, str) } ) return { "schema_version": "local_git_remote_inventory_v1", "status": "partial" if repos else "empty", "roots": [str(root) for root in roots], "max_depth": max_depth, "gitea_host_fragment": gitea_fragment, "repo_count": len(repos), "gitea_linked_count": len(gitea_linked), "github_linked_count": len(github_linked), "mapped_count": len(mapped), "gitea_only_count": len(gitea_only), "github_only_count": len(github_only), "internal_110_only_count": len(internal_110), "unique_gitea_repo_count": len(unique_gitea), "unique_github_repo_count": len(unique_github), "unique_internal_110_repo_count": len(unique_internal_110), "unique_gitea_repos": unique_gitea, "unique_github_repos": unique_github, "unique_internal_110_repos": unique_internal_110, "repos": repos, } def write_markdown(inventory: dict[str, object], path: Path) -> None: lines = [ "# 本機 Git Remote 盤點快照", "", "| 項目 | 值 |", "|------|----|", f"| 狀態 | `{inventory['status']}` |", f"| 掃描 root | `{', '.join(inventory['roots'])}` |", f"| max depth | `{inventory['max_depth']}` |", f"| Gitea host fragment | `{inventory['gitea_host_fragment']}` |", f"| repo 數量 | `{inventory['repo_count']}` |", f"| Gitea linked | `{inventory['gitea_linked_count']}` |", f"| GitHub linked | `{inventory['github_linked_count']}` |", f"| mapped | `{inventory['mapped_count']}` |", f"| Gitea-only local | `{inventory['gitea_only_count']}` |", f"| GitHub-only local | `{inventory['github_only_count']}` |", f"| Internal 110-only local | `{inventory['internal_110_only_count']}` |", f"| 去重後 Gitea repo | `{inventory['unique_gitea_repo_count']}` |", f"| 去重後 GitHub repo | `{inventory['unique_github_repo_count']}` |", f"| 去重後 110 內部 repo | `{inventory['unique_internal_110_repo_count']}` |", "", "## Repo 對照", "", "| 狀態 | 本機路徑 | Gitea repo | GitHub repo | 110 內部 remote |", "|------|----------|------------|-------------|----------------|", ] repos = inventory.get("repos") if isinstance(repos, list): for repo in repos: if not isinstance(repo, dict): continue gitea = ", ".join(f"`{item}`" for item in repo.get("gitea_repos", [])) or "-" github = ", ".join(f"`{item}`" for item in repo.get("github_repos", [])) or "-" internal_110 = ( ", ".join(f"`{item}`" for item in repo.get("internal_110_repos", [])) or "-" ) lines.append( "| " + " | ".join( [ f"`{repo.get('status', '')}`", f"`{repo.get('repo_path', '')}`", gitea, github, internal_110, ] ) + " |" ) lines.extend( [ "", "> 注意:本檔只代表本機指定 roots 可見的 Git working tree,不等同 Gitea server 全量 repo 清單。", "> 輸出前已移除 remote URL 中的 username、password、token。", "", ] ) path.write_text("\n".join(lines), encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--root", action="append", required=True) parser.add_argument("--max-depth", type=int, default=4) parser.add_argument("--exclude-name", action="append", default=[]) parser.add_argument("--gitea-host-fragment", default="192.168.0.110:3001") parser.add_argument("--output-json", required=True) parser.add_argument("--output-md", required=True) args = parser.parse_args() roots = [Path(root).expanduser().resolve() for root in args.root] exclude_names = set(DEFAULT_EXCLUDE_NAMES) exclude_names.update(args.exclude_name) inventory = build_inventory( roots=roots, max_depth=args.max_depth, exclude_names=exclude_names, gitea_fragment=args.gitea_host_fragment, ) Path(args.output_json).write_text( json.dumps(inventory, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) write_markdown(inventory, Path(args.output_md)) if inventory["status"] == "empty": print("沒有找到本機 Git working tree", file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main())