Add original git-tools plugin

This commit is contained in:
CJYSEX
2026-07-22 23:38:14 +08:00
parent ad9403f7fc
commit 9a24a2125f
9 changed files with 405 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
---
name: manage-git-repositories
description: Safely inspect, initialize, publish, synchronize, and troubleshoot Git repositories across local Git, Gitea, GitHub, GitLab, and generic SSH or HTTPS remotes. Use when the user asks about repository status, commits, branches, remotes, clone or push failures, SSH keys, Git credentials, publishing a project, configuring a primary remote plus a mirror, or recovering from Git conflicts without losing local work.
---
# Manage Git Repositories
Operate conservatively. Preserve local work, verify exact targets, and show evidence before changing repository state.
## Safety Rules
- Never display, commit, log, or transmit passwords, access tokens, private keys, cookies, or credential-bearing URLs.
- Redact URL user information, token query parameters, and authorization headers.
- Treat existing modified, staged, and untracked files as user-owned work.
- Do not run `reset --hard`, forced checkout, clean, history rewriting, force push, recursive deletion, or branch deletion unless the user explicitly requests the exact destructive operation.
- Before a merge, rebase, cherry-pick, remote replacement, or branch rewrite, inspect status and explain the impact.
- Prefer non-interactive Git commands and explicit repository paths.
- Do not create commits, tags, releases, pull requests, or remote repositories unless the user requested publication or a change.
## Start With Diagnosis
Run the bundled read-only diagnostic script:
```bash
python <skill-dir>/scripts/git_diagnose.py <repository-path>
```
Use `--json` for structured output. Use `--check-remotes` only when remote network access is relevant because it can trigger authentication.
Then confirm:
1. Repository root and current branch.
2. Modified, staged, untracked, conflicted, ahead, and behind counts.
3. Upstream branch and every remote URL after redaction.
4. Git author configuration.
5. Whether the intended remote is reachable.
## Initialize Or Publish
For an ordinary new repository:
1. Inspect the directory for secrets, generated files, and existing user work.
2. Create or review `.gitignore`.
3. Initialize Git only when no repository exists.
4. Configure commit identity only with user-approved values.
5. Review the exact staged diff.
6. Commit with a specific message.
7. Add the requested remote.
8. Fetch the remote before the first push when it already contains commits.
9. Merge unrelated initial content carefully instead of overwriting it.
10. Push without force and verify the remote commit hash.
## Configure Gitea And GitHub Mirrors
Prefer clear remote roles:
```text
gitea primary customer-accessible repository
origin GitHub or another public mirror
```
Before synchronizing:
- Verify both remotes point to the intended repositories.
- Fetch both remotes.
- Compare local `HEAD` with each remote branch.
- Push to the primary first, verify it, then push the identical commit to the mirror.
- Report partial success when one remote is unavailable.
Do not silently force two divergent histories to match.
## Troubleshoot Clone And Push
Diagnose in layers:
1. DNS resolution.
2. TCP connectivity to HTTPS 443 or SSH port.
3. TLS certificate and hostname.
4. Git client and proxy configuration.
5. SSH config, selected identity, and host key.
6. Repository existence.
7. User or team permission.
8. Branch protection and non-fast-forward rejection.
Interpret common failures:
- `connection reset`: network path, proxy, CDN, or remote endpoint.
- `permission denied (publickey)`: wrong key, wrong SSH client, missing account key, or `IdentitiesOnly` mismatch.
- `authentication failed`: expired token, wrong credential helper entry, or insufficient scope.
- `repository not found`: wrong owner/path or no read permission.
- `non-fast-forward`: remote contains commits not present locally; fetch and integrate.
- `unrelated histories`: remote and local were initialized independently; preserve both and merge deliberately.
## Commit And Review
Before committing:
- Run `git diff --check`.
- Review `git status --short`.
- Review staged paths and staged diff.
- Scan staged content for likely credentials.
- Avoid mixing unrelated user changes into the commit.
After pushing:
- Compare local and remote branch hashes.
- Report the branch and commit hash.
- State which remotes succeeded.
## Output
Return:
1. Current repository state.
2. Actions taken.
3. Remote and branch results.
4. Remaining risk or user action.
Do not claim a push, mirror, or authentication succeeded without a command result proving it.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Git Tools"
short_description: "安全管理 Git、Gitea 和 GitHub 仓库"
default_prompt: "检查当前仓库状态并给出安全的下一步。"

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import pathlib
import re
import subprocess
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
TOKEN_KEYS = {
"access_token",
"api_key",
"apikey",
"auth",
"key",
"password",
"signature",
"token",
}
def git(repo: pathlib.Path, *args: str, timeout: int = 20) -> tuple[int, str, str]:
try:
result = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=False,
)
except FileNotFoundError:
return 127, "", "git executable not found"
except subprocess.TimeoutExpired:
return 124, "", "git command timed out"
return result.returncode, result.stdout.strip(), result.stderr.strip()
def redact_url(value: str) -> str:
if re.match(r"^[^/@\s]+@[^:\s]+:.+$", value):
user_host, path = value.split(":", 1)
user, host = user_host.split("@", 1)
return f"{user}@{host}:{path}"
try:
parts = urlsplit(value)
except ValueError:
return "[invalid remote URL]"
if not parts.scheme or not parts.netloc:
return value
host = parts.hostname or ""
if parts.port:
host = f"{host}:{parts.port}"
query = []
for key, item in parse_qsl(parts.query, keep_blank_values=True):
query.append((key, "[REDACTED]" if key.lower() in TOKEN_KEYS else item))
return urlunsplit((parts.scheme, host, parts.path, urlencode(query), parts.fragment))
def get(repo: pathlib.Path, *args: str) -> str | None:
code, out, _ = git(repo, *args)
return out if code == 0 and out else None
def parse_status(text: str) -> dict[str, int]:
result = {"staged": 0, "modified": 0, "untracked": 0, "conflicted": 0}
conflict_codes = {"DD", "AU", "UD", "UA", "DU", "AA", "UU"}
for line in text.splitlines():
if len(line) < 2:
continue
code = line[:2]
if code == "??":
result["untracked"] += 1
continue
if code in conflict_codes:
result["conflicted"] += 1
if code[0] not in {" ", "?"}:
result["staged"] += 1
if code[1] not in {" ", "?"}:
result["modified"] += 1
return result
def diagnose(repo: pathlib.Path, check_remotes: bool) -> dict[str, object]:
repo = repo.resolve()
code, root, error = git(repo, "rev-parse", "--show-toplevel")
if code != 0:
return {"ok": False, "path": str(repo), "error": error or "not a Git repository"}
root_path = pathlib.Path(root)
status_text = get(root_path, "status", "--porcelain=v1", "--untracked-files=all") or ""
branch = get(root_path, "branch", "--show-current")
detached = get(root_path, "rev-parse", "--short", "HEAD") if not branch else None
upstream = get(root_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
ahead = behind = None
if upstream:
counts = get(root_path, "rev-list", "--left-right", "--count", f"HEAD...{upstream}")
if counts:
left, right = counts.split()
ahead, behind = int(left), int(right)
remotes: list[dict[str, object]] = []
for name in (get(root_path, "remote") or "").splitlines():
url = get(root_path, "remote", "get-url", name) or ""
item: dict[str, object] = {"name": name, "url": redact_url(url)}
if check_remotes:
remote_code, _, remote_error = git(
root_path, "ls-remote", "--exit-code", "--heads", name, timeout=30
)
item["reachable"] = remote_code == 0
if remote_code != 0:
item["error"] = re.sub(
r"(?i)(password|token|authorization)[^\s]*",
"[REDACTED]",
remote_error[:300],
)
remotes.append(item)
return {
"ok": True,
"root": str(root_path),
"branch": branch or f"detached@{detached or 'unknown'}",
"head": get(root_path, "rev-parse", "HEAD"),
"upstream": upstream,
"ahead": ahead,
"behind": behind,
"status": parse_status(status_text),
"author": {
"name": get(root_path, "config", "--get", "user.name"),
"email": get(root_path, "config", "--get", "user.email"),
},
"remotes": remotes,
"last_commit": get(root_path, "log", "-1", "--format=%h %ad %s", "--date=iso-strict"),
}
def render_human(report: dict[str, object]) -> str:
if not report.get("ok"):
return f"Git diagnosis failed: {report.get('error')} ({report.get('path')})"
lines = [
f"Repository: {report['root']}",
f"Branch: {report['branch']}",
f"HEAD: {report.get('head')}",
f"Upstream: {report.get('upstream') or '-'}",
f"Ahead/behind: {report.get('ahead')}/{report.get('behind')}",
]
status = report["status"]
lines.append(
"Changes: staged={staged}, modified={modified}, untracked={untracked}, "
"conflicted={conflicted}".format(**status)
)
author = report["author"]
lines.append(
f"Author: {author.get('name') or '[unset]'} <{author.get('email') or '[unset]'}>"
)
lines.append("Remotes:")
for remote in report["remotes"]:
suffix = ""
if "reachable" in remote:
suffix = " reachable" if remote["reachable"] else " unreachable"
lines.append(f" - {remote['name']}: {remote['url']}{suffix}")
lines.append(f"Last commit: {report.get('last_commit') or '-'}")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description="Read-only Git repository diagnostics.")
parser.add_argument("path", nargs="?", default=".")
parser.add_argument("--json", action="store_true", dest="as_json")
parser.add_argument("--check-remotes", action="store_true")
args = parser.parse_args()
report = diagnose(pathlib.Path(args.path), args.check_remotes)
print(json.dumps(report, ensure_ascii=False, indent=2) if args.as_json else render_human(report))
return 0 if report.get("ok") else 2
if __name__ == "__main__":
raise SystemExit(main())