docs(security): add supply chain contract manifest [skip ci]

This commit is contained in:
Your Name
2026-05-12 23:23:17 +08:00
parent c652f37b69
commit 8ce318ac1c
65 changed files with 8608 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Git remote refs 只讀探測工具。
此工具用 `git ls-remote --heads --tags` 讀取指定本機 repo 的 remote refs
並比對本機 HEAD / branch。它不 fetch、不 clone、不 push也不修改 remote。
"""
from __future__ import annotations
import argparse
import json
import subprocess
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
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 run_git(repo: Path, args: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
["git", *args],
cwd=repo,
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(["git", *args], 124, "", "git command timeout")
def git_value(repo: Path, args: list[str], timeout: int) -> str:
result = run_git(repo, args, timeout)
if result.returncode != 0:
return ""
return result.stdout.strip()
def parse_refs(output: str) -> tuple[dict[str, str], dict[str, str]]:
heads: dict[str, str] = {}
tags: dict[str, str] = {}
for line in output.splitlines():
parts = line.split()
if len(parts) != 2:
continue
sha, ref = parts
if ref.startswith("refs/heads/"):
heads[ref.removeprefix("refs/heads/")] = sha
elif ref.startswith("refs/tags/"):
tag = ref.removeprefix("refs/tags/").removesuffix("^{}")
tags[tag] = sha
return heads, tags
def parse_repo_arg(value: str) -> tuple[str, Path, str]:
parts = value.split("=")
if len(parts) not in (2, 3):
raise argparse.ArgumentTypeError("--repo 必須是 label=/absolute/path 或 label=/absolute/path=remote")
label = parts[0].strip()
path = Path(parts[1].strip()).expanduser().resolve()
remote = parts[2].strip() if len(parts) == 3 else "origin"
if not label or not str(path) or not remote:
raise argparse.ArgumentTypeError("--repo label/path/remote 不可為空")
return label, path, remote
def probe_repo(label: str, repo: Path, remote: str, timeout: int) -> dict[str, object]:
local_head = git_value(repo, ["rev-parse", "HEAD"], timeout)
local_branch = git_value(repo, ["branch", "--show-current"], timeout)
remote_url = git_value(repo, ["remote", "get-url", remote], timeout)
ls_remote = run_git(repo, ["ls-remote", "--heads", "--tags", remote], timeout)
if ls_remote.returncode == 0:
heads, tags = parse_refs(ls_remote.stdout)
remote_branch_sha = heads.get(local_branch, "")
if local_branch and remote_branch_sha and remote_branch_sha == local_head:
status = "aligned_current_branch"
elif heads:
status = "reachable_drift_or_unknown"
else:
status = "reachable_no_heads"
error_summary = ""
else:
heads = {}
tags = {}
remote_branch_sha = ""
status = "unreachable"
stderr = ls_remote.stderr.strip()
if "Permission denied" in stderr:
error_summary = "SSH 權限不足或 remote 不可讀"
elif "Repository not found" in stderr:
error_summary = "remote repo not found 或未授權"
else:
tail = (stderr or ls_remote.stdout.strip()).splitlines()[-1:]
error_summary = tail[0] if tail else "git ls-remote failed"
return {
"label": label,
"repo_path": str(repo),
"remote": remote,
"remote_url_redacted": redact_url(remote_url),
"status": status,
"local_branch": local_branch,
"local_head": local_head,
"remote_current_branch_sha": remote_branch_sha,
"head_count": len(heads),
"tag_count": len(tags),
"heads": [{"name": name, "sha": sha} for name, sha in sorted(heads.items())],
"tags": [{"name": name, "sha": sha} for name, sha in sorted(tags.items())],
"error_summary": error_summary,
}
def build_payload(group_name: str, repo_args: list[tuple[str, Path, str]], timeout: int) -> dict[str, object]:
repos = [probe_repo(label, path, remote, timeout) for label, path, remote in repo_args]
aligned = sum(1 for repo in repos if repo["status"] == "aligned_current_branch")
unreachable = sum(1 for repo in repos if repo["status"] == "unreachable")
return {
"schema_version": "git_remote_refs_probe_v1",
"group_name": group_name,
"status": "ok" if unreachable == 0 else "partial",
"repo_count": len(repos),
"aligned_current_branch_count": aligned,
"unreachable_count": unreachable,
"repos": repos,
}
def write_markdown(payload: dict[str, object], path: Path) -> None:
lines = [
"# Git Remote Refs Probe 快照",
"",
"| 項目 | 值 |",
"|------|----|",
f"| 群組 | `{payload['group_name']}` |",
f"| 狀態 | `{payload['status']}` |",
f"| repo 數 | `{payload['repo_count']}` |",
f"| aligned current branch | `{payload['aligned_current_branch_count']}` |",
f"| unreachable | `{payload['unreachable_count']}` |",
"",
"## Repo refs",
"",
"| Label | Remote URL | Status | Local branch | Local HEAD | Remote branch SHA | Heads | Tags | Error |",
"|-------|------------|--------|--------------|------------|-------------------|-------|------|-------|",
]
for repo in payload.get("repos", []):
if not isinstance(repo, dict):
continue
lines.append(
"| "
+ " | ".join(
[
f"`{repo.get('label', '')}`",
f"`{repo.get('remote_url_redacted', '')}`",
f"`{repo.get('status', '')}`",
f"`{repo.get('local_branch', '')}`",
f"`{str(repo.get('local_head', ''))[:7]}`",
f"`{str(repo.get('remote_current_branch_sha', ''))[:7]}`",
f"`{repo.get('head_count', 0)}`",
f"`{repo.get('tag_count', 0)}`",
str(repo.get("error_summary", "") or ""),
]
)
+ " |"
)
lines.extend(
[
"",
"> 注意:本檔只使用 `git ls-remote` 做 read-only refs 探測;未 fetch、未 clone、未 push。",
"",
]
)
path.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--group-name", required=True)
parser.add_argument("--repo", action="append", type=parse_repo_arg, required=True)
parser.add_argument("--output-json", required=True)
parser.add_argument("--output-md", required=True)
parser.add_argument("--timeout", type=int, default=10)
args = parser.parse_args()
payload = build_payload(args.group_name, args.repo, args.timeout)
Path(args.output_json).write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_markdown(payload, Path(args.output_md))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Read-only Gitea repo 全量盤點工具。
此工具可查詢 Gitea org/user API endpoint或吃管理介面匯出的 JSON。
執行期間不寫入 Gitea也不會把 API token 寫入輸出檔。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
from urllib.parse import quote, urlencode, urlsplit, urlunsplit
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 api_get_json(url: str, token: str | None, timeout: int) -> tuple[int, object]:
headers = {
"Accept": "application/json",
"User-Agent": "awoooi-security-inventory/1.0",
}
if token:
headers["Authorization"] = f"token {token}"
request = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read().decode("utf-8")
return response.status, json.loads(body)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
try:
payload: object = json.loads(body)
except json.JSONDecodeError:
payload = {"message": body.strip()}
return exc.code, payload
def repo_summary(repo: dict[str, object], github_owner: str | None) -> dict[str, object]:
owner = repo.get("owner") if isinstance(repo.get("owner"), dict) else {}
owner_name = owner.get("login") if isinstance(owner, dict) else None
raw_full_name = str(repo.get("full_name") or "")
name = str(repo.get("name") or raw_full_name.rsplit("/", 1)[-1] or "")
full_name = raw_full_name or (f"{owner_name}/{name}" if owner_name else name)
clone_url = str(repo.get("clone_url") or repo.get("html_url") or "")
ssh_url = str(repo.get("ssh_url") or "")
github_repo = f"{github_owner}/{name}" if github_owner and name else ""
return {
"gitea_repo": full_name,
"name": name,
"owner": owner_name or "",
"private": bool(repo.get("private", False)),
"empty": bool(repo.get("empty", False)),
"archived": bool(repo.get("archived", False)),
"default_branch": str(repo.get("default_branch") or ""),
"clone_url_redacted": redact_url(clone_url),
"ssh_url_redacted": redact_url(ssh_url),
"github_repo_candidate": github_repo,
}
def load_export(path: Path) -> object:
return json.loads(path.read_text(encoding="utf-8"))
def extract_repos(payload: object) -> list[dict[str, object]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
for key in ("data", "repos", "repositories"):
value = payload.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def build_inventory(
*,
base_url: str,
org: str,
github_owner: str | None,
token_present: bool,
http_status: int | None,
payload: object,
query_mode: str,
query: str,
) -> dict[str, object]:
repos = [repo_summary(repo, github_owner) for repo in extract_repos(payload)]
if query_mode == "export":
visibility_scope = "admin_export"
elif token_present:
visibility_scope = "authenticated"
else:
visibility_scope = "public_only"
if repos and visibility_scope in ("authenticated", "admin_export"):
status = "ok"
elif repos:
status = "partial"
else:
status = "blocked"
blocking_reason = ""
if repos and status == "partial":
blocking_reason = "未提供 token結果只代表公開可見 repoprivate/internal repos 仍需只讀 token 或管理匯出"
elif not repos:
if http_status in (401, 403):
blocking_reason = "Gitea API 需要只讀 token 或權限不足"
elif query_mode == "search" and http_status == 200:
blocking_reason = "Gitea public repo search 未回傳 repo可能沒有公開 repo 或需要只讀 token"
elif http_status == 404:
blocking_reason = "Gitea API 查無 org/user repos需確認 org 名稱或使用管理匯出"
elif http_status is None:
blocking_reason = "匯入檔案沒有可解析的 repo list"
else:
blocking_reason = f"Gitea API 回應無 repo listHTTP {http_status}"
return {
"schema_version": "gitea_repo_inventory_v1",
"base_url": redact_url(base_url.rstrip("/")),
"org": org,
"github_owner": github_owner or "",
"query_mode": query_mode,
"query": query,
"visibility_scope": visibility_scope,
"token_present": token_present,
"http_status": http_status,
"status": status,
"blocking_reason": blocking_reason,
"repo_count": len(repos),
"repos": repos,
}
def write_markdown(inventory: dict[str, object], path: Path) -> None:
lines = [
"# Gitea Repo 全量盤點快照",
"",
"| 項目 | 值 |",
"|------|----|",
f"| 狀態 | `{inventory['status']}` |",
f"| Gitea base URL | `{inventory['base_url']}` |",
f"| Org/User | `{inventory['org']}` |",
f"| GitHub owner 候選 | `{inventory['github_owner']}` |",
f"| 查詢模式 | `{inventory.get('query_mode', '')}` |",
f"| 查詢字串 | `{inventory.get('query', '')}` |",
f"| 可見性範圍 | `{inventory.get('visibility_scope', '')}` |",
f"| 是否提供 token | `{inventory['token_present']}` |",
f"| HTTP status | `{inventory['http_status']}` |",
f"| Repo 數量 | `{inventory['repo_count']}` |",
f"| 阻塞原因 | {inventory['blocking_reason'] or ''} |",
"",
"## Repo 清單",
"",
"| Gitea repo | GitHub 候選 | default branch | private | archived |",
"|------------|------------------|----------------|---------|----------|",
]
repos = inventory.get("repos")
if isinstance(repos, list):
for repo in repos:
if not isinstance(repo, dict):
continue
lines.append(
"| "
+ " | ".join(
[
f"`{repo.get('gitea_repo', '')}`",
f"`{repo.get('github_repo_candidate', '')}`",
f"`{repo.get('default_branch', '')}`",
f"`{repo.get('private', '')}`",
f"`{repo.get('archived', '')}`",
]
)
+ " |"
)
lines.extend(
[
"",
"> 注意:本檔由 read-only Gitea inventory 工具產生,不包含 API token 或 remote URL 帳密。",
"",
]
)
path.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", default="http://192.168.0.110:3001")
parser.add_argument("--org", default="wooo")
parser.add_argument("--scope", choices=["org", "user", "search"], default="org")
parser.add_argument("--query", default="")
parser.add_argument("--limit", type=int, default=50)
parser.add_argument("--github-owner", default="")
parser.add_argument("--token-env", default="GITEA_READONLY_TOKEN")
parser.add_argument("--input-json")
parser.add_argument("--output-json", required=True)
parser.add_argument("--output-md", required=True)
parser.add_argument("--timeout", type=int, default=5)
args = parser.parse_args()
token = os.environ.get(args.token_env)
http_status: int | None = None
if args.input_json:
payload = load_export(Path(args.input_json))
query_mode = "export"
query = "input-json"
else:
if args.scope == "search":
params = {"limit": str(args.limit)}
if args.query:
params["q"] = args.query
url = f"{args.base_url.rstrip('/')}/api/v1/repos/search?{urlencode(params)}"
else:
quoted = quote(args.org, safe="")
prefix = "orgs" if args.scope == "org" else "users"
url = f"{args.base_url.rstrip('/')}/api/v1/{prefix}/{quoted}/repos"
http_status, payload = api_get_json(url, token, args.timeout)
query_mode = args.scope
query = args.query
inventory = build_inventory(
base_url=args.base_url,
org=args.org,
github_owner=args.github_owner or None,
token_present=bool(token),
http_status=http_status,
payload=payload,
query_mode=query_mode,
query=query,
)
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"] == "blocked":
print(inventory["blocking_reason"], file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""GitHub target repo 只讀存在性探測。
此工具使用 `git ls-remote --heads` 檢查候選 GitHub repo 是否可讀。
它不 clone、不 fetch、不 push也不寫入任何 remote。
"""
from __future__ import annotations
import argparse
import json
import subprocess
from pathlib import Path
def probe_candidate(candidate: str, timeout: int) -> dict[str, object]:
url = f"https://github.com/{candidate}.git"
try:
result = subprocess.run(
["git", "ls-remote", "--heads", url],
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return {
"github_repo": candidate,
"url_redacted": url,
"status": "timeout",
"head_count": 0,
"heads": [],
"error_summary": "git ls-remote timeout",
}
heads = []
if result.returncode == 0:
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) != 2 or not parts[1].startswith("refs/heads/"):
continue
heads.append(
{
"name": parts[1].removeprefix("refs/heads/"),
"sha": parts[0],
}
)
status = "exists" if heads else "exists_empty_or_no_heads"
error_summary = ""
else:
stderr = result.stderr.strip()
if "Repository not found" in stderr:
status = "not_found_or_private"
error_summary = "GitHub 回應 repository not found可能未建立或為 private 且未授權"
else:
status = "error"
error_summary = stderr.splitlines()[-1] if stderr else "git ls-remote failed"
return {
"github_repo": candidate,
"url_redacted": url,
"status": status,
"head_count": len(heads),
"heads": heads,
"error_summary": error_summary,
}
def write_markdown(payload: dict[str, object], path: Path) -> None:
lines = [
"# GitHub Target Probe 快照",
"",
"| 項目 | 值 |",
"|------|----|",
f"| 狀態 | `{payload['status']}` |",
f"| 候選 repo 數 | `{payload['candidate_count']}` |",
f"| exists | `{payload['exists_count']}` |",
f"| not found or private | `{payload['not_found_or_private_count']}` |",
"",
"## 候選 Repo",
"",
"| GitHub repo | status | heads | error |",
"|-------------|--------|-------|-------|",
]
for candidate in payload.get("candidates", []):
if not isinstance(candidate, dict):
continue
lines.append(
"| "
+ " | ".join(
[
f"`{candidate.get('github_repo', '')}`",
f"`{candidate.get('status', '')}`",
f"`{candidate.get('head_count', 0)}`",
str(candidate.get("error_summary", "") or ""),
]
)
+ " |"
)
lines.extend(
[
"",
"> 注意:`not_found_or_private` 只代表未授權 read-only probe 看不到,不等同確認不存在。",
"",
]
)
path.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--candidate", action="append", required=True)
parser.add_argument("--output-json", required=True)
parser.add_argument("--output-md", required=True)
parser.add_argument("--timeout", type=int, default=10)
args = parser.parse_args()
candidates = [probe_candidate(candidate, args.timeout) for candidate in args.candidate]
exists_count = sum(1 for item in candidates if item["status"].startswith("exists"))
not_found_count = sum(1 for item in candidates if item["status"] == "not_found_or_private")
payload = {
"schema_version": "github_target_probe_v1",
"status": "ok",
"candidate_count": len(candidates),
"exists_count": exists_count,
"not_found_or_private_count": not_found_count,
"candidates": candidates,
}
Path(args.output_json).write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_markdown(payload, Path(args.output_md))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,352 @@
#!/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())

View File

@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""本機 repo canonical lineage 只讀探測。
此工具比較多個本機 Git working tree 的 HEAD、branch、remote 與近期
commit ancestry協助判斷它們是否可能屬於同一個 canonical repo。
它不 fetch、不push、不修改 remote也不讀取 commit message。
"""
from __future__ import annotations
import argparse
import configparser
import json
import subprocess
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
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 run_git(repo: Path, args: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
["git", *args],
cwd=repo,
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(["git", *args], 124, "", "git command timeout")
def git_value(repo: Path, args: list[str], timeout: int) -> str:
result = run_git(repo, args, timeout)
if result.returncode != 0:
return ""
return result.stdout.strip()
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) -> 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
remotes.append(
{
"name": name,
"url_redacted": redact_url(parser.get(section, "url").strip()),
}
)
return remotes
def parse_repo_arg(value: str) -> tuple[str, Path]:
if "=" not in value:
raise argparse.ArgumentTypeError("--repo 必須是 label=/absolute/path")
label, raw_path = value.split("=", 1)
if not label.strip() or not raw_path.strip():
raise argparse.ArgumentTypeError("--repo label 與 path 不可為空")
return label.strip(), Path(raw_path).expanduser().resolve()
def repo_summary(label: str, repo_path: Path, sample_limit: int, git_timeout: int) -> dict[str, object]:
exists = (repo_path / ".git").exists()
if not exists:
return {
"label": label,
"repo_path": str(repo_path),
"exists": False,
"head_sha": "",
"head_short": "",
"branch": "",
"commit_sample_count": 0,
"commits": [],
"remotes": [],
"probe_error": "repo missing",
}
probe_errors: list[str] = []
head_result = run_git(repo_path, ["rev-parse", "HEAD"], git_timeout)
head_sha = head_result.stdout.strip() if head_result.returncode == 0 else ""
if head_result.returncode != 0:
probe_errors.append("HEAD 讀取失敗或逾時")
branch = git_value(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"], git_timeout)
rev_list_result = run_git(repo_path, ["rev-list", f"--max-count={sample_limit}", "HEAD"], git_timeout)
commits = rev_list_result.stdout.splitlines() if rev_list_result.returncode == 0 else []
if rev_list_result.returncode != 0:
probe_errors.append("rev-list 讀取失敗或逾時")
return {
"label": label,
"repo_path": str(repo_path),
"exists": True,
"head_sha": head_sha,
"head_short": head_sha[:7],
"branch": branch,
"commit_sample_count": len(commits),
"commits": commits,
"remotes": read_remotes(repo_path),
"probe_error": "".join(probe_errors),
}
def compare_repos(left: dict[str, object], right: dict[str, object]) -> dict[str, object]:
left_commits = set(left.get("commits", []))
right_commits = set(right.get("commits", []))
left_head = str(left.get("head_sha") or "")
right_head = str(right.get("head_sha") or "")
common = sorted(left_commits & right_commits)
if not left.get("exists") or not right.get("exists"):
relation = "missing_repo"
elif left_head and left_head == right_head:
relation = "same_head"
elif left.get("probe_error") or right.get("probe_error"):
relation = "partial_probe"
elif right_head and right_head in left_commits:
relation = "left_descends_from_right"
elif left_head and left_head in right_commits:
relation = "right_descends_from_left"
elif common:
relation = "shared_history"
else:
relation = "no_shared_history"
return {
"left_label": left["label"],
"right_label": right["label"],
"relation": relation,
"left_head": left_head,
"right_head": right_head,
"common_commit_count": len(common),
"common_commit_samples": common[:5],
}
def build_payload(
group_name: str,
repo_args: list[tuple[str, Path]],
sample_limit: int,
git_timeout: int,
) -> dict[str, object]:
repos = [repo_summary(label, path, sample_limit, git_timeout) for label, path in repo_args]
comparisons = []
for left_index, left in enumerate(repos):
for right in repos[left_index + 1 :]:
comparisons.append(compare_repos(left, right))
partial = any(item["relation"] == "partial_probe" for item in comparisons)
related = any(
item["relation"]
in ("same_head", "left_descends_from_right", "right_descends_from_left", "shared_history")
for item in comparisons
)
no_shared = any(item["relation"] == "no_shared_history" for item in comparisons)
if partial:
status = "partial"
elif related and no_shared:
status = "mixed"
elif related:
status = "related"
elif comparisons:
status = "unrelated"
else:
status = "partial"
return {
"schema_version": "local_repo_canonical_probe_v1",
"group_name": group_name,
"status": status,
"sample_limit": sample_limit,
"git_timeout_seconds": git_timeout,
"repo_count": len(repos),
"comparison_count": len(comparisons),
"repos": repos,
"comparisons": comparisons,
}
def write_markdown(payload: dict[str, object], path: Path) -> None:
lines = [
"# 本機 Repo Canonical Lineage Probe 快照",
"",
"| 項目 | 值 |",
"|------|----|",
f"| 群組 | `{payload['group_name']}` |",
f"| 狀態 | `{payload['status']}` |",
f"| repo 數 | `{payload['repo_count']}` |",
f"| 比對數 | `{payload['comparison_count']}` |",
f"| sample limit | `{payload['sample_limit']}` |",
f"| git timeout seconds | `{payload['git_timeout_seconds']}` |",
"",
"## Repo HEAD",
"",
"| Label | Path | Branch | HEAD | Remotes |",
"|-------|------|--------|------|---------|",
]
for repo in payload.get("repos", []):
if not isinstance(repo, dict):
continue
remotes = repo.get("remotes", [])
remote_text = ", ".join(
f"`{remote.get('name', '')}:{remote.get('url_redacted', '')}`"
for remote in remotes
if isinstance(remote, dict)
)
lines.append(
"| "
+ " | ".join(
[
f"`{repo.get('label', '')}`",
f"`{repo.get('repo_path', '')}`",
f"`{repo.get('branch', '')}`",
f"`{repo.get('head_short', '')}`",
remote_text or "-",
]
)
+ " |"
)
lines.extend(["", "## Lineage 比對", "", "| Left | Right | Relation | Common commits |", "|------|-------|----------|----------------|"])
for comparison in payload.get("comparisons", []):
if not isinstance(comparison, dict):
continue
lines.append(
"| "
+ " | ".join(
[
f"`{comparison.get('left_label', '')}`",
f"`{comparison.get('right_label', '')}`",
f"`{comparison.get('relation', '')}`",
f"`{comparison.get('common_commit_count', 0)}`",
]
)
+ " |"
)
lines.extend(
[
"",
"> 注意:本檔只比較本機 Git 物件,未 fetch 遠端common commit sample 只用 SHA不含 commit message。",
"",
]
)
path.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--group-name", required=True)
parser.add_argument("--repo", action="append", type=parse_repo_arg, required=True)
parser.add_argument("--sample-limit", type=int, default=5000)
parser.add_argument("--git-timeout", type=int, default=10)
parser.add_argument("--output-json", required=True)
parser.add_argument("--output-md", required=True)
args = parser.parse_args()
payload = build_payload(args.group_name, args.repo, args.sample_limit, args.git_timeout)
Path(args.output_json).write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_markdown(payload, Path(args.output_md))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""Read-only Gitea/GitHub 遷移盤點工具。
此工具只用 `git ls-remote` 比對兩端 refs不 push、不 fetch、不寫入任一 remote。
寫入 evidence 前會先遮蔽 remote URL 內的帳密。
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
SHA_RE = re.compile(r"^[0-9a-fA-F]{7,40}$")
@dataclass(frozen=True)
class GitRefs:
heads: dict[str, str]
tags: dict[str, str]
raw_tag_ref_count: int
def run_git(repo: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=repo,
check=False,
capture_output=True,
text=True,
)
def require_git(repo: Path, args: list[str]) -> str:
result = run_git(repo, args)
if result.returncode != 0:
stderr = result.stderr.strip() or result.stdout.strip()
raise RuntimeError(f"git {' '.join(args)} failed: {stderr}")
return result.stdout
def redact_url(url: str) -> str:
"""Remove userinfo from common URL formats before storing evidence."""
if "://" in url:
parts = urlsplit(url)
netloc = parts.netloc
if "@" in netloc:
netloc = netloc.split("@", 1)[1]
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
# scp-like syntax: user@host:path
if "@" in url and ":" in url.split("@", 1)[1]:
return url.split("@", 1)[1]
return url
def remote_url(repo: Path, remote: str) -> str:
return redact_url(require_git(repo, ["remote", "get-url", remote]).strip())
def repo_slug_from_url(url: str) -> str:
redacted = redact_url(url).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") or redacted
def parse_ls_remote(output: str, prefix: str) -> dict[str, str]:
refs: dict[str, str] = {}
for line in output.splitlines():
if not line.strip():
continue
try:
sha, ref = line.split(None, 1)
except ValueError:
continue
if not SHA_RE.match(sha) or not ref.startswith(prefix):
continue
name = ref.removeprefix(prefix)
refs[name] = sha
return refs
def refs_for_remote(repo: Path, remote: str) -> GitRefs:
heads_out = require_git(repo, ["ls-remote", "--heads", remote])
tags_out = require_git(repo, ["ls-remote", "--tags", remote])
raw_tags = parse_ls_remote(tags_out, "refs/tags/")
tags = {
name.removesuffix("^{}"): sha
for name, sha in raw_tags.items()
if not name.endswith("^{}")
}
peeled = {
name.removesuffix("^{}"): sha
for name, sha in raw_tags.items()
if name.endswith("^{}")
}
tags.update({name: peeled_sha for name, peeled_sha in peeled.items()})
return GitRefs(
heads=parse_ls_remote(heads_out, "refs/heads/"),
tags=tags,
raw_tag_ref_count=len(raw_tags),
)
def compare_maps(left: dict[str, str], right: dict[str, str]) -> dict[str, object]:
left_names = set(left)
right_names = set(right)
common = sorted(left_names & right_names)
return {
"only_left": sorted(left_names - right_names),
"only_right": sorted(right_names - left_names),
"sha_mismatch": [
{
"name": name,
"left_sha": left[name],
"right_sha": right[name],
}
for name in common
if left[name] != right[name]
],
"matching": [name for name in common if left[name] == right[name]],
}
def build_inventory(repo: Path, gitea_remote: str, github_remote: str) -> dict[str, object]:
gitea = refs_for_remote(repo, gitea_remote)
github = refs_for_remote(repo, github_remote)
gitea_url = remote_url(repo, gitea_remote)
github_url = remote_url(repo, github_remote)
head_diff = compare_maps(gitea.heads, github.heads)
tag_diff = compare_maps(gitea.tags, github.tags)
latest_sha_gitea = gitea.heads.get("main", "")
latest_sha_github = github.heads.get("main", "")
status = "verified"
blocking_reasons: list[str] = []
if head_diff["only_left"] or head_diff["only_right"] or head_diff["sha_mismatch"]:
status = "blocked"
blocking_reasons.append("branches 尚未完全對齊")
if tag_diff["only_left"] or tag_diff["only_right"] or tag_diff["sha_mismatch"]:
status = "blocked"
blocking_reasons.append("tags 尚未完全對齊")
if latest_sha_gitea and latest_sha_github and latest_sha_gitea != latest_sha_github:
status = "blocked"
blocking_reasons.append("main SHA 不一致")
if not latest_sha_gitea or not latest_sha_github:
status = "blocked"
blocking_reasons.append("其中一端缺少 main")
return {
"repo_path": str(repo),
"gitea_remote": gitea_remote,
"github_remote": github_remote,
"gitea_url_redacted": gitea_url,
"github_url_redacted": github_url,
"gitea_repo": repo_slug_from_url(gitea_url),
"github_repo": repo_slug_from_url(github_url),
"branch_count_gitea": len(gitea.heads),
"branch_count_github": len(github.heads),
"tag_count_gitea": len(gitea.tags),
"tag_count_github": len(github.tags),
"raw_tag_ref_count_gitea": gitea.raw_tag_ref_count,
"raw_tag_ref_count_github": github.raw_tag_ref_count,
"latest_sha_gitea": latest_sha_gitea,
"latest_sha_github": latest_sha_github,
"workflows_mapped": False,
"webhooks_mapped": False,
"secrets_inventory_only": True,
"status": status,
"blocking_reason": "".join(blocking_reasons) if blocking_reasons else "",
"heads": head_diff,
"tags": tag_diff,
}
def event_payload(inventory: dict[str, object], evidence_ref: str | None) -> dict[str, object]:
payload = {
"schema_version": "source_control_migration_event_v1",
"gitea_repo": inventory["gitea_repo"],
"github_repo": inventory["github_repo"],
"branch_count_gitea": inventory["branch_count_gitea"],
"branch_count_github": inventory["branch_count_github"],
"tag_count_gitea": inventory["tag_count_gitea"],
"tag_count_github": inventory["tag_count_github"],
"latest_sha_gitea": inventory["latest_sha_gitea"],
"latest_sha_github": inventory["latest_sha_github"],
"workflows_mapped": inventory["workflows_mapped"],
"webhooks_mapped": inventory["webhooks_mapped"],
"secrets_inventory_only": inventory["secrets_inventory_only"],
"status": inventory["status"],
"blocking_reason": inventory["blocking_reason"],
}
if evidence_ref:
payload["evidence_refs"] = [evidence_ref]
return payload
def write_markdown(inventory: dict[str, object], path: Path) -> None:
heads = inventory["heads"]
tags = inventory["tags"]
assert isinstance(heads, dict)
assert isinstance(tags, dict)
lines = [
"# Source Control 遷移盤點快照",
"",
"| 項目 | 值 |",
"|------|----|",
f"| 狀態 | `{inventory['status']}` |",
f"| Gitea remote | `{inventory['gitea_remote']}` |",
f"| GitHub remote | `{inventory['github_remote']}` |",
f"| Gitea repo | `{inventory['gitea_repo']}` |",
f"| GitHub repo | `{inventory['github_repo']}` |",
f"| Gitea URL | `{inventory['gitea_url_redacted']}` |",
f"| GitHub URL | `{inventory['github_url_redacted']}` |",
f"| Gitea 分支數 | `{inventory['branch_count_gitea']}` |",
f"| GitHub 分支數 | `{inventory['branch_count_github']}` |",
f"| Gitea tags | `{inventory['tag_count_gitea']}` |",
f"| GitHub tags | `{inventory['tag_count_github']}` |",
f"| Gitea main | `{inventory['latest_sha_gitea']}` |",
f"| GitHub main | `{inventory['latest_sha_github']}` |",
f"| 阻塞原因 | {inventory['blocking_reason'] or ''} |",
"",
"## 分支差異",
"",
f"- 只在 Gitea`{len(heads['only_left'])}`",
f"- 只在 GitHub`{len(heads['only_right'])}`",
f"- SHA 不一致:`{len(heads['sha_mismatch'])}`",
f"- SHA 一致:`{len(heads['matching'])}`",
"",
"## Tag 差異",
"",
f"- 只在 Gitea`{len(tags['only_left'])}`",
f"- 只在 GitHub`{len(tags['only_right'])}`",
f"- SHA 不一致:`{len(tags['sha_mismatch'])}`",
f"- SHA 一致:`{len(tags['matching'])}`",
"",
"> 注意:本檔由 read-only inventory 工具產生,不包含 remote URL 內的帳密。",
"",
]
path.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo", default=".")
parser.add_argument("--gitea-remote", default="gitea")
parser.add_argument("--github-remote", default="origin")
parser.add_argument("--output-json")
parser.add_argument("--output-md")
args = parser.parse_args()
repo = Path(args.repo).resolve()
try:
inventory = build_inventory(repo, args.gitea_remote, args.github_remote)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 2
payload = json.dumps(
event_payload(inventory, args.output_md),
ensure_ascii=False,
indent=2,
)
if args.output_json:
Path(args.output_json).write_text(payload + "\n", encoding="utf-8")
else:
print(payload)
if args.output_md:
write_markdown(inventory, Path(args.output_md))
return 0
if __name__ == "__main__":
raise SystemExit(main())