Add gitea-repo-sync skill
这个提交包含在:
119
gitea-repo-sync/SKILL.md
普通文件
119
gitea-repo-sync/SKILL.md
普通文件
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: gitea-repo-sync
|
||||
description: 在自建或托管 Gitea 上自动创建仓库、初始化或复用本地 Git 项目、配置远程并安全推送分支/标签。适用于“给当前目录建 Gitea 仓库”“同步本地项目到 Gitea”“给组织创建仓库再推送代码”这类请求。
|
||||
metadata:
|
||||
short-description: Create Gitea repositories and sync local projects safely
|
||||
---
|
||||
# Gitea Repo Sync
|
||||
|
||||
在 Gitea 上建仓、连远程、推送代码的标准化技能。
|
||||
|
||||
优先使用打包好的 `scripts/gitea_repo_sync.py`,不要重复手写零散 `curl` + `git` 命令。脚本会先探测仓库是否已存在,再按需要创建仓库、初始化本地 Git、配置远程并执行 push。
|
||||
|
||||
## 什么时候用
|
||||
|
||||
- 用户要把当前目录项目同步到 Gitea
|
||||
- 用户要在某个 owner 或 organization 下创建新仓库
|
||||
- 本地目录还不是 Git 仓库,但需要初始化后推送
|
||||
- 需要复用已有远程仓库,而不是盲目重复创建
|
||||
- 需要在自动化里稳定处理 token、remote 和 push 顺序
|
||||
|
||||
## 需要的输入
|
||||
|
||||
- `GITEA_URL`:例如 `https://git.hk.hao.work`
|
||||
- `GITEA_TOKEN`:具备建仓和推送权限的 token
|
||||
- `owner`:用户名或组织名,例如 `hao`
|
||||
- `repo`:仓库名,例如 `demo-project`
|
||||
- `source_dir`:要同步的本地目录
|
||||
- 可选 `branch`:默认推送的分支名,常见为 `main`
|
||||
- 可选 `description` / `private` / `tags`
|
||||
|
||||
优先使用环境变量:
|
||||
|
||||
```bash
|
||||
export GITEA_URL='https://git.hk.hao.work'
|
||||
export GITEA_TOKEN='<your_token>'
|
||||
```
|
||||
|
||||
## 标准流程
|
||||
|
||||
1. 先确认本地目录路径、目标 owner 和 repo 名。
|
||||
2. 调 Gitea API 查询仓库是否已存在。
|
||||
3. 若不存在,则按 owner 类型创建:
|
||||
- 当前登录用户自己的仓库:`POST /api/v1/user/repos`
|
||||
- 组织仓库:`POST /api/v1/orgs/{org}/repos`
|
||||
4. 进入本地目录检查是否为 Git 仓库:
|
||||
- 不是 Git 仓库时,只有在明确需要时才初始化
|
||||
- 已经是 Git 仓库时,优先复用现有提交历史
|
||||
5. 配置或校验远程:
|
||||
- 无 remote 时新增
|
||||
- remote 已存在但 URL 不同,默认停止并提示;只有明确允许时才替换
|
||||
6. 按请求推送:
|
||||
- 单分支:`push --set-upstream`
|
||||
- 全部分支:`push --all`
|
||||
- 标签:`push --tags`
|
||||
7. 输出最终仓库 URL、remote URL、本地分支和 push 结果。
|
||||
|
||||
## 安全默认值
|
||||
|
||||
- 不强制覆盖现有 remote,除非明确传 `--replace-remote`
|
||||
- 不自动 `git add` / `git commit`,除非明确传 `--stage-all` 和 `--commit-message`
|
||||
- 不自动 `force push`
|
||||
- 不把 token 写进 git remote URL
|
||||
- 仓库已存在时优先复用,不重复创建
|
||||
|
||||
## 推荐脚本
|
||||
|
||||
使用 `scripts/gitea_repo_sync.py`。
|
||||
|
||||
### 常见用法
|
||||
|
||||
```bash
|
||||
python3 scripts/gitea_repo_sync.py \
|
||||
--server-url "$GITEA_URL" \
|
||||
--token "$GITEA_TOKEN" \
|
||||
--owner hao \
|
||||
--repo demo-project \
|
||||
--source-dir /path/to/project \
|
||||
--init-git \
|
||||
--branch main \
|
||||
--stage-all \
|
||||
--commit-message 'Initial import'
|
||||
```
|
||||
|
||||
如果仓库已存在,只同步当前分支:
|
||||
|
||||
```bash
|
||||
python3 scripts/gitea_repo_sync.py \
|
||||
--server-url "$GITEA_URL" \
|
||||
--token "$GITEA_TOKEN" \
|
||||
--owner hao \
|
||||
--repo demo-project \
|
||||
--source-dir /path/to/project \
|
||||
--branch main
|
||||
```
|
||||
|
||||
推送全部分支和标签:
|
||||
|
||||
```bash
|
||||
python3 scripts/gitea_repo_sync.py \
|
||||
--server-url "$GITEA_URL" \
|
||||
--token "$GITEA_TOKEN" \
|
||||
--owner hao \
|
||||
--repo demo-project \
|
||||
--source-dir /path/to/project \
|
||||
--push-all \
|
||||
--tags
|
||||
```
|
||||
|
||||
## 常见坑
|
||||
|
||||
- 不要把 `GITEA_TOKEN` 提交进仓库。
|
||||
- 不要在 remote URL 里长期保存带 token 的认证串。
|
||||
- 不要在 remote 已存在时静默改写 URL。
|
||||
- 不要在工作区有未确认修改时自动提交;先确认是否需要 `--stage-all`。
|
||||
- 不要默认 `--push-all`;很多场景只需要当前分支。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- `references/gitea-api-notes.md`
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Gitea Repo Sync"
|
||||
short_description: "Create Gitea repositories and sync local projects safely"
|
||||
default_prompt: "Create or reuse a Gitea repository, initialize the current project as git if needed, configure a safe remote, and push the requested branches or tags without storing the token in the remote URL."
|
||||
@@ -0,0 +1,49 @@
|
||||
# Gitea API Notes
|
||||
|
||||
本技能默认对接 Gitea v1.25.x,核心只依赖少量稳定接口:
|
||||
|
||||
- `GET /api/v1/user`
|
||||
- `GET /api/v1/repos/{owner}/{repo}`
|
||||
- `POST /api/v1/user/repos`
|
||||
- `POST /api/v1/orgs/{org}/repos`
|
||||
|
||||
## 创建仓库的 owner 选择
|
||||
|
||||
1. 先调用 `GET /api/v1/user` 获取当前 token 对应登录名。
|
||||
2. 如果目标 `owner` 与当前登录名一致,走 `POST /api/v1/user/repos`。
|
||||
3. 如果目标 `owner` 不同,按组织仓库处理,走 `POST /api/v1/orgs/{org}/repos`。
|
||||
|
||||
## 推荐请求头
|
||||
|
||||
```text
|
||||
Authorization: token <token>
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
## Git Push 认证建议
|
||||
|
||||
不要把 token 写入 `git remote -v`。
|
||||
|
||||
优先使用一次性的 HTTP header:
|
||||
|
||||
- 用户名:Gitea 登录名
|
||||
- 密码:token
|
||||
- Header:`Authorization: Basic <base64(username:token)>`
|
||||
|
||||
这样可以在 push 时认证,但不会把 token 持久化到仓库配置里。
|
||||
|
||||
## 建议的环境变量
|
||||
|
||||
```bash
|
||||
export GITEA_URL='https://git.hk.hao.work'
|
||||
export GITEA_TOKEN='<your_token>'
|
||||
```
|
||||
|
||||
## 失败时优先检查
|
||||
|
||||
- token 是否有 repo 创建/写入权限
|
||||
- `owner` 是用户还是组织
|
||||
- 本地目录是否已经是 Git 仓库
|
||||
- 当前分支是否存在提交
|
||||
- 远程 URL 是否已指向别的仓库
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(f"[gitea-repo-sync] {message}", flush=True)
|
||||
|
||||
|
||||
def api_request(server_url: str, token: str, method: str, path: str, payload=None, ok_not_found: bool = False):
|
||||
url = f"{server_url.rstrip('/')}/api/v1/{path.lstrip('/')}"
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(url, data=data, method=method.upper(), headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
return response.status, json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
if ok_not_found and exc.code == 404:
|
||||
return 404, None
|
||||
raise RuntimeError(f"HTTP {exc.code} {method} {url}: {body}") from exc
|
||||
|
||||
|
||||
def run_git(args, cwd: Path, auth_header: Optional[str] = None, capture: bool = True) -> str:
|
||||
cmd = ["git"]
|
||||
if auth_header:
|
||||
cmd += ["-c", f"http.extraHeader={auth_header}"]
|
||||
cmd += args
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=capture,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or "").strip()
|
||||
stdout = (result.stdout or "").strip()
|
||||
raise RuntimeError(f"git {' '.join(args)} failed: {stderr or stdout}")
|
||||
return (result.stdout or "").strip()
|
||||
|
||||
|
||||
def is_git_repo(source_dir: Path) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
cwd=str(source_dir),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0 and (result.stdout or "").strip() == "true"
|
||||
|
||||
|
||||
def ensure_git_repo(source_dir: Path, branch: str, init_git: bool) -> None:
|
||||
if is_git_repo(source_dir):
|
||||
return
|
||||
if not init_git:
|
||||
raise RuntimeError(f"{source_dir} 不是 Git 仓库;如需初始化请传 --init-git")
|
||||
log(f"初始化 Git 仓库: {source_dir}")
|
||||
run_git(["init", "-b", branch], cwd=source_dir)
|
||||
|
||||
|
||||
def branch_name_from_head(source_dir: Path) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "symbolic-ref", "--short", "HEAD"],
|
||||
cwd=str(source_dir),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return (result.stdout or "").strip()
|
||||
return run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=source_dir)
|
||||
|
||||
|
||||
def ensure_branch(source_dir: Path, branch: str) -> None:
|
||||
current = branch_name_from_head(source_dir)
|
||||
if current == branch:
|
||||
return
|
||||
result = subprocess.run(
|
||||
["git", "show-ref", "--verify", f"refs/heads/{branch}"],
|
||||
cwd=str(source_dir),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
run_git(["checkout", branch], cwd=source_dir)
|
||||
else:
|
||||
run_git(["checkout", "-b", branch], cwd=source_dir)
|
||||
|
||||
|
||||
def maybe_commit(source_dir: Path, stage_all: bool, commit_message: Optional[str]) -> None:
|
||||
if not stage_all and not commit_message:
|
||||
return
|
||||
if stage_all:
|
||||
run_git(["add", "-A"], cwd=source_dir)
|
||||
if commit_message:
|
||||
status = run_git(["status", "--porcelain"], cwd=source_dir)
|
||||
if status:
|
||||
log("创建提交")
|
||||
run_git(["commit", "-m", commit_message], cwd=source_dir)
|
||||
|
||||
|
||||
def get_current_branch(source_dir: Path) -> str:
|
||||
return branch_name_from_head(source_dir)
|
||||
|
||||
|
||||
def get_remote_url(source_dir: Path, remote_name: str) -> Optional[str]:
|
||||
result = subprocess.run(
|
||||
["git", "remote", "get-url", remote_name],
|
||||
cwd=str(source_dir),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return (result.stdout or "").strip()
|
||||
|
||||
|
||||
def ensure_remote(source_dir: Path, remote_name: str, remote_url: str, replace_remote: bool) -> None:
|
||||
current = get_remote_url(source_dir, remote_name)
|
||||
if not current:
|
||||
run_git(["remote", "add", remote_name, remote_url], cwd=source_dir)
|
||||
return
|
||||
if current == remote_url:
|
||||
return
|
||||
if not replace_remote:
|
||||
raise RuntimeError(
|
||||
f"remote {remote_name} 已存在且指向 {current};如需替换请显式传 --replace-remote"
|
||||
)
|
||||
run_git(["remote", "set-url", remote_name, remote_url], cwd=source_dir)
|
||||
|
||||
|
||||
def basic_auth_header(username: str, token: str) -> str:
|
||||
raw = f"{username}:{token}".encode("utf-8")
|
||||
return "Authorization: Basic " + base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def ensure_repo(server_url: str, token: str, owner: str, repo: str, description: str, private: bool, default_branch: str):
|
||||
_, user = api_request(server_url, token, "GET", "/user")
|
||||
username = user["login"]
|
||||
status, existing = api_request(server_url, token, "GET", f"/repos/{owner}/{repo}", ok_not_found=True)
|
||||
if status == 200 and existing:
|
||||
return username, existing, False
|
||||
|
||||
payload = {
|
||||
"name": repo,
|
||||
"description": description,
|
||||
"private": private,
|
||||
"auto_init": False,
|
||||
"default_branch": default_branch,
|
||||
}
|
||||
if owner == username:
|
||||
_, created = api_request(server_url, token, "POST", "/user/repos", payload=payload)
|
||||
else:
|
||||
_, created = api_request(server_url, token, "POST", f"/orgs/{owner}/repos", payload=payload)
|
||||
return username, created, True
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Create or reuse a Gitea repository and sync a local project.")
|
||||
parser.add_argument("--server-url", default=os.getenv("GITEA_URL", ""), help="Gitea base URL")
|
||||
parser.add_argument("--token", default=os.getenv("GITEA_TOKEN", ""), help="Gitea API token")
|
||||
parser.add_argument("--owner", required=True, help="Repo owner or organization")
|
||||
parser.add_argument("--repo", required=True, help="Repository name")
|
||||
parser.add_argument("--source-dir", required=True, help="Local project path")
|
||||
parser.add_argument("--description", default="", help="Repository description")
|
||||
parser.add_argument("--branch", default="main", help="Branch to push")
|
||||
parser.add_argument("--remote-name", default="origin", help="Git remote name")
|
||||
parser.add_argument("--private", action="store_true", help="Create private repository")
|
||||
parser.add_argument("--init-git", action="store_true", help="Initialize git when source-dir is not a repository")
|
||||
parser.add_argument("--replace-remote", action="store_true", help="Replace remote URL when remote exists but differs")
|
||||
parser.add_argument("--stage-all", action="store_true", help="Run git add -A before commit")
|
||||
parser.add_argument("--commit-message", default="", help="Create a commit before pushing")
|
||||
parser.add_argument("--push-all", action="store_true", help="Push all local branches")
|
||||
parser.add_argument("--tags", action="store_true", help="Push tags after branch push")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if not args.server_url:
|
||||
raise RuntimeError("缺少 --server-url 或 GITEA_URL")
|
||||
if not args.token:
|
||||
raise RuntimeError("缺少 --token 或 GITEA_TOKEN")
|
||||
|
||||
source_dir = Path(args.source_dir).expanduser().resolve()
|
||||
if not source_dir.exists() or not source_dir.is_dir():
|
||||
raise RuntimeError(f"source-dir 不存在或不是目录: {source_dir}")
|
||||
|
||||
username, repo_info, created = ensure_repo(
|
||||
server_url=args.server_url,
|
||||
token=args.token,
|
||||
owner=args.owner,
|
||||
repo=args.repo,
|
||||
description=args.description,
|
||||
private=args.private,
|
||||
default_branch=args.branch,
|
||||
)
|
||||
remote_url = repo_info.get("clone_url") or repo_info.get("html_url", "").rstrip("/") + ".git"
|
||||
if not remote_url:
|
||||
raise RuntimeError("未能从 Gitea 响应中获取 clone_url")
|
||||
|
||||
ensure_git_repo(source_dir, args.branch, args.init_git)
|
||||
ensure_branch(source_dir, args.branch)
|
||||
maybe_commit(source_dir, args.stage_all, args.commit_message or None)
|
||||
ensure_remote(source_dir, args.remote_name, remote_url, args.replace_remote)
|
||||
|
||||
auth_header = basic_auth_header(username, args.token)
|
||||
branch = get_current_branch(source_dir)
|
||||
if args.push_all:
|
||||
log("推送全部分支")
|
||||
run_git(["push", args.remote_name, "--all"], cwd=source_dir, auth_header=auth_header, capture=True)
|
||||
else:
|
||||
log(f"推送分支: {branch}")
|
||||
run_git(["push", "--set-upstream", args.remote_name, branch], cwd=source_dir, auth_header=auth_header, capture=True)
|
||||
|
||||
if args.tags:
|
||||
log("推送标签")
|
||||
run_git(["push", args.remote_name, "--tags"], cwd=source_dir, auth_header=auth_header, capture=True)
|
||||
|
||||
summary = {
|
||||
"created": created,
|
||||
"owner": args.owner,
|
||||
"repo": args.repo,
|
||||
"branch": branch,
|
||||
"source_dir": str(source_dir),
|
||||
"remote_name": args.remote_name,
|
||||
"remote_url": remote_url,
|
||||
"html_url": repo_info.get("html_url"),
|
||||
}
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
在新工单中引用
屏蔽一个用户