590 lines
24 KiB
Python
590 lines
24 KiB
Python
#!/usr/bin/env python3
|
||
"""推送到 Gitea 仓库 —— 通用 / 鲁棒版。
|
||
|
||
用法:
|
||
python3 gitea_push.py <repo_name> <description> [paths...] [options]
|
||
|
||
典型场景:
|
||
1. 新目录 → 新仓库
|
||
python3 gitea_push.py remind-me "飞书加急提醒" ./skills/remind-me/
|
||
2. 增量更新已有仓库(同样命令,自动拉远程历史后提交)
|
||
python3 gitea_push.py remind-me "飞书加急提醒" ./skills/remind-me/
|
||
3. 推送一个已有 git 仓库(保留完整提交历史)
|
||
python3 gitea_push.py my-proj "我的项目" --from-git /root/my-proj
|
||
4. 只检查不推送
|
||
python3 gitea_push.py my-proj "我的项目" ./src --dry-run
|
||
|
||
环境变量:
|
||
GITEA_URL 实例地址(默认 https://git.yoresee.cc)
|
||
GITEA_USER 用户名(默认 NightStar)
|
||
GITEA_EMAIL 提交邮箱(默认 nightstar@yoresee.cc)
|
||
GITEA_SSH_HOST SSH 主机(默认 git.yoresee.cc)
|
||
|
||
退出码: 0 成功 / 1 一般错误 / 2 隐私检查未通过 / 3 推送被拒(重试后仍失败)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import fnmatch
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
# ─────────────────────────── 配置 ───────────────────────────
|
||
|
||
GITEA_URL = os.environ.get("GITEA_URL", "https://git.yoresee.cc").rstrip("/")
|
||
GITEA_USER = os.environ.get("GITEA_USER", "NightStar")
|
||
GITEA_EMAIL = os.environ.get("GITEA_EMAIL", "nightstar@yoresee.cc")
|
||
GITEA_SSH_HOST = os.environ.get("GITEA_SSH_HOST", "git.yoresee.cc")
|
||
DEFAULT_BRANCH = "master"
|
||
PUSH_RETRIES = 3
|
||
|
||
# 隐私模式:命中即拦(--allow-leaks 可放行)
|
||
SECRET_PATTERNS = [
|
||
(r"ghp_[A-Za-z0-9]{20,}", "GitHub PAT (ghp_)"),
|
||
(r"github_pat_[A-Za-z0-9_]{20,}", "GitHub fine-grained PAT"),
|
||
(r"sk-[A-Za-z0-9]{20,}", "API key (sk-)"),
|
||
(r"sk-sp-[A-Za-z0-9\-_]{20,}", "Token Plan key (sk-sp-)"),
|
||
(r"AKIA[0-9A-Z]{16}", "AWS Access Key ID"),
|
||
(r"xox[baprs]-[A-Za-z0-9\-]{10,}", "Slack token"),
|
||
(r"cli_[a-zA-Z0-9]{10,}", "飞书 app_id (cli_)"),
|
||
(r"ou_[a-f0-9]{20,}", "飞书 open_id (ou_)"),
|
||
(r"t-[a-zA-Z0-9]{20,}", "token (t-)"),
|
||
(r"Bearer\s+[A-Za-z0-9\-_.]{20,}", "Bearer 凭据"),
|
||
(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", "私钥文件"),
|
||
(r"(?i)\b(password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*[\"']?[^\s\"',{}]{12,}", "明文口令/密钥赋值"),
|
||
]
|
||
|
||
# IP:仅拦公网地址(保留 127./10./192.168./172.16-31./0.0.0.0/255. 等常见非敏感写法)
|
||
IP_RE = re.compile(r"\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b")
|
||
|
||
# 扫描的文本类扩展名(旧版漏了 .html/.css/.vue 等,这里补齐)
|
||
SCAN_EXTS = {
|
||
".py", ".pyi", ".md", ".markdown", ".json", ".json5", ".yaml", ".yml",
|
||
".sh", ".bash", ".zsh", ".fish", ".txt", ".toml", ".ini", ".cfg", ".conf", ".properties",
|
||
".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".vue", ".svelte",
|
||
".html", ".htm", ".css", ".scss", ".less", ".xml", ".svg",
|
||
".go", ".rs", ".java", ".kt", ".rb", ".php", ".sql", ".pl", ".lua",
|
||
".env", ".envrc", ".tf", ".tfvars", ".gradle", ".cmake",
|
||
}
|
||
SCAN_NAMES = {
|
||
"Dockerfile", "Makefile", "Rakefile", "Gemfile", "Procfile",
|
||
".env", ".env.local", ".env.production", ".npmrc", ".pypirc", ".netrc",
|
||
}
|
||
|
||
ALWAYS_EXCLUDE_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".mypy_cache",
|
||
".pytest_cache", ".ruff_cache", "dist", "build", ".next", ".nuxt",
|
||
"target", ".idea", ".vscode", "vendor"}
|
||
ALWAYS_EXCLUDE_FILES = {".DS_Store", "Thumbs.db"}
|
||
MAX_WARN_BYTES = 5 * 1024 * 1024 # 单文件超 5MB 提醒
|
||
|
||
|
||
class PushError(Exception):
|
||
def __init__(self, msg: str, code: int = 1):
|
||
super().__init__(msg)
|
||
self.code = code
|
||
|
||
|
||
# ─────────────────────────── 日志 ───────────────────────────
|
||
|
||
QUIET = False
|
||
|
||
|
||
def log(msg: str = "", *, err: bool = True) -> None:
|
||
if not QUIET:
|
||
print(msg, file=sys.stderr if err else sys.stdout)
|
||
|
||
|
||
def step(n: int, total: int, msg: str) -> None:
|
||
log(f"[{n}/{total}] {msg}")
|
||
|
||
|
||
# ─────────────────────────── 子进程 ───────────────────────────
|
||
|
||
def _ssh_env() -> dict:
|
||
env = dict(os.environ)
|
||
env["GIT_SSH_COMMAND"] = env.get(
|
||
"GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new"
|
||
)
|
||
return env
|
||
|
||
|
||
def run(cmd: list[str], cwd: Path | None = None, *, check: bool = True,
|
||
env: dict | None = None) -> subprocess.CompletedProcess:
|
||
res = subprocess.run(cmd, cwd=str(cwd) if cwd else None, capture_output=True,
|
||
text=True, env=env)
|
||
if check and res.returncode != 0:
|
||
raise PushError(f"命令失败: {' '.join(cmd)}\n{res.stderr.strip()}")
|
||
return res
|
||
|
||
|
||
def git(args: list[str], cwd: Path, *, check: bool = True) -> subprocess.CompletedProcess:
|
||
return run(["git", *args], cwd=cwd, check=check, env=_ssh_env())
|
||
|
||
|
||
def tea(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess:
|
||
return run(["tea", *args], check=check)
|
||
|
||
|
||
def has_commits(cwd: Path) -> bool:
|
||
return git(["rev-parse", "--verify", "HEAD"], cwd, check=False).returncode == 0
|
||
|
||
|
||
def remote_branch_exists(cwd: Path, branch: str) -> bool:
|
||
return git(["rev-parse", "--verify", f"origin/{branch}"], cwd,
|
||
check=False).returncode == 0
|
||
|
||
|
||
# ─────────────────────────── 隐私检查 ───────────────────────────
|
||
|
||
def _is_scannable(p: Path) -> bool:
|
||
if p.name in SCAN_NAMES:
|
||
return True
|
||
return p.suffix.lower() in SCAN_EXTS
|
||
|
||
|
||
# 跳过不算敏感的四类:本机/私网/保留段、以及 RFC 5737 文档专用网段
|
||
_DOC_RANGES = ((192, 0, 2), (198, 51, 100), (203, 0, 113))
|
||
|
||
|
||
def _public_ip(match: re.Match) -> bool:
|
||
"""判定是否公网 IP(跳过本机/私网/环回/广播/文档专用段等无害写法)。"""
|
||
a, b, c = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||
if a == 0 or a == 127 or a == 255:
|
||
return False
|
||
if a == 10 or (a == 192 and b == 168) or (a == 172 and 16 <= b <= 31):
|
||
return False
|
||
if a == 169 and b == 254:
|
||
return False
|
||
if (a, b, c) in _DOC_RANGES:
|
||
return False
|
||
# 其余一律报出,交人工判断(版本号式写法也宁可多报)
|
||
return True
|
||
|
||
|
||
def scan_paths(paths: list[Path]) -> list[dict]:
|
||
"""扫描文件中的疑似隐私。返回命中列表。"""
|
||
findings: list[dict] = []
|
||
for root in paths:
|
||
files = [root] if root.is_file() else [f for f in root.rglob("*") if f.is_file()]
|
||
for f in files:
|
||
if any(part in ALWAYS_EXCLUDE_DIRS for part in f.parts):
|
||
continue
|
||
if not _is_scannable(f):
|
||
continue
|
||
try:
|
||
text = f.read_text(encoding="utf-8", errors="ignore")
|
||
except OSError:
|
||
continue
|
||
hits: list[str] = []
|
||
for pattern, label in SECRET_PATTERNS:
|
||
n = len(re.findall(pattern, text))
|
||
if n:
|
||
hits.append(f"{label} × {n}")
|
||
pub_ips = [m.group(0) for m in IP_RE.finditer(text) if _public_ip(m)]
|
||
if pub_ips:
|
||
uniq = sorted(set(pub_ips))
|
||
hits.append(f"公网 IP × {len(uniq)}: {', '.join(uniq[:3])}"
|
||
+ (" …" if len(uniq) > 3 else ""))
|
||
if hits:
|
||
findings.append({"file": str(f), "hits": hits})
|
||
return findings
|
||
|
||
|
||
# ─────────────────────────── 文件收集 ───────────────────────────
|
||
|
||
def _fnmatch_fallback(patterns: list[str], rel: str) -> bool:
|
||
"""pathspec 不可用时的兜底匹配(够用即可)。"""
|
||
for pat in patterns:
|
||
pat = pat.rstrip("/")
|
||
if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(rel, f"{pat}/*"):
|
||
return True
|
||
if pat.startswith("**/") and fnmatch.fnmatch(rel, pat[3:]):
|
||
return True
|
||
return False
|
||
|
||
|
||
def load_ignore_spec(sources: list[Path]):
|
||
"""收集各源目录向上查找的 .gitignore 规则。"""
|
||
patterns: list[str] = []
|
||
seen: set[str] = set()
|
||
for src in sources:
|
||
p = src.resolve()
|
||
if p.is_file():
|
||
p = p.parent
|
||
while True:
|
||
gi = p / ".gitignore"
|
||
if str(gi) not in seen and gi.is_file():
|
||
seen.add(str(gi))
|
||
try:
|
||
for line in gi.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||
line = line.strip()
|
||
if line and not line.startswith("#") and line != ".gitignore":
|
||
patterns.append(line)
|
||
except OSError:
|
||
pass
|
||
if p.parent == p:
|
||
break
|
||
p = p.parent
|
||
try:
|
||
import pathspec # type: ignore
|
||
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
||
except ImportError:
|
||
log(" (pathspec 未安装,使用 fnmatch 兜底匹配)")
|
||
return patterns
|
||
|
||
|
||
def is_ignored(spec, rel: str) -> bool:
|
||
if isinstance(spec, list):
|
||
return _fnmatch_fallback(spec, rel)
|
||
try:
|
||
return spec.match_file(rel)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def collect_files(sources: list[Path], work_dir: Path, spec, extra_excludes: list[str]) -> int:
|
||
"""把源文件复制进工作区(保持相对结构)。返回文件数。"""
|
||
count = 0
|
||
for src in sources:
|
||
src = src.resolve()
|
||
if src.is_file():
|
||
(work_dir / src.name).write_bytes(src.read_bytes())
|
||
count += 1
|
||
continue
|
||
if not src.is_dir():
|
||
log(f" ⚠️ 跳过不存在的路径: {src}")
|
||
continue
|
||
for f in sorted(src.rglob("*")):
|
||
if not f.is_file():
|
||
continue
|
||
if any(part in ALWAYS_EXCLUDE_DIRS for part in f.parts):
|
||
continue
|
||
if f.name in ALWAYS_EXCLUDE_FILES or f.name.endswith((".pyc", ".pyo")):
|
||
continue
|
||
rel = str(f.relative_to(src))
|
||
if is_ignored(spec, rel):
|
||
continue
|
||
if any(fnmatch.fnmatch(rel, pat) for pat in extra_excludes):
|
||
continue
|
||
dest = work_dir / rel
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(f, dest)
|
||
size = f.stat().st_size
|
||
if size > MAX_WARN_BYTES:
|
||
log(f" ⚠️ 大文件 {rel} ({size // 1024 // 1024} MB)")
|
||
count += 1
|
||
return count
|
||
|
||
|
||
# ─────────────────────────── 仓库准备 ───────────────────────────
|
||
|
||
def login_user() -> str:
|
||
"""取 tea 当前登录用户名(失败回退到 GITEA_USER)。"""
|
||
res = tea(["login", "list", "--output", "json"], check=False)
|
||
if res.returncode == 0:
|
||
try:
|
||
entries = json.loads(res.stdout or "[]")
|
||
for e in entries:
|
||
if e.get("user"):
|
||
return str(e["user"])
|
||
except (json.JSONDecodeError, TypeError):
|
||
pass
|
||
return GITEA_USER
|
||
|
||
|
||
def ensure_repo(repo: str, description: str, owner: str, branch: str,
|
||
private: bool, dry_run: bool) -> bool:
|
||
"""确保仓库存在。返回是否新建。
|
||
|
||
⚠️ tea 0.15.0 的坑:给自己命名空间建仓时传 `--owner <自己>` 会报
|
||
`Error: not found`;必须省略 --owner。只有 owner ≠ 登录用户(建到
|
||
组织/他人命名空间)时才传。
|
||
"""
|
||
if dry_run:
|
||
log(" (dry-run:跳过建仓)")
|
||
return False
|
||
existed = False
|
||
self_owner = login_user().lower()
|
||
cmd = ["repos", "create", "--name", repo, "--description", description,
|
||
"--branch", branch, "--init"]
|
||
if owner.lower() != self_owner:
|
||
cmd += ["--owner", owner]
|
||
if private:
|
||
cmd.append("--private")
|
||
res = tea(cmd, check=False)
|
||
if res.returncode == 0:
|
||
out = (res.stdout or "") + (res.stderr or "")
|
||
if "not found" in out.lower():
|
||
raise PushError(
|
||
f"创建仓库返回 not found(owner={owner} 可能不存在或无权访问):\n{out.strip()[:300]}")
|
||
return True
|
||
err = (res.stderr + res.stdout).lower()
|
||
if "already exists" in err or "409" in err:
|
||
existed = True
|
||
else:
|
||
# 二次确认:列一下 owner 的仓库
|
||
listing = tea(["repos", "list", "--owner", owner, "--output", "json",
|
||
"--limit", "200"], check=False)
|
||
if listing.returncode == 0:
|
||
try:
|
||
names = {r.get("name") for r in json.loads(listing.stdout or "[]")}
|
||
if repo in names:
|
||
existed = True
|
||
except json.JSONDecodeError:
|
||
pass
|
||
if not existed:
|
||
raise PushError(f"创建仓库失败: {res.stderr.strip() or res.stdout.strip()}")
|
||
log(f" 仓库 {owner}/{repo} 已存在,走增量提交(不 force)")
|
||
return False
|
||
|
||
|
||
def prepare_worktree(sources: list[Path], repo: str, owner: str, branch: str,
|
||
from_git: Path | None, extra_excludes: list[str]) -> tuple[Path, bool]:
|
||
"""准备工作区。返回 (路径, 是否为临时目录)。"""
|
||
if from_git:
|
||
work = from_git.resolve()
|
||
if not (work / ".git").exists():
|
||
raise PushError(f"{work} 不是 git 仓库(--from-git 需要已有 .git)")
|
||
remotes = git(["remote"], work, check=False).stdout.split()
|
||
url = f"git@{GITEA_SSH_HOST}:{owner}/{repo}.git"
|
||
if "origin" in remotes:
|
||
git(["remote", "set-url", "origin", url], work)
|
||
else:
|
||
git(["remote", "add", "origin", url], work)
|
||
return work, False
|
||
|
||
work = Path(tempfile.mkdtemp(prefix=f"gitea_{repo}_"))
|
||
run(["git", "init", "-b", branch], work)
|
||
git(["config", "user.name", GITEA_USER], work)
|
||
git(["config", "user.email", GITEA_EMAIL], work)
|
||
git(["remote", "add", "origin", f"git@{GITEA_SSH_HOST}:{owner}/{repo}.git"], work)
|
||
spec = load_ignore_spec(sources)
|
||
n = collect_files(sources, work, spec, extra_excludes)
|
||
log(f" 共收集 {n} 个文件")
|
||
return work, True
|
||
|
||
|
||
def align_with_remote(work: Path, branch: str, *, from_git: bool) -> str:
|
||
"""把本地分支对齐到远程历史,避免 'fetch first' 拒绝。
|
||
|
||
非 --from-git 场景用 `reset --mixed`(而非 --hard):只移动 HEAD、
|
||
不碰工作区,避免覆盖同名文件的用户内容。
|
||
"""
|
||
git(["fetch", "origin", branch], work, check=False)
|
||
if not remote_branch_exists(work, branch):
|
||
return "no-remote-branch"
|
||
if not has_commits(work):
|
||
# 未出生分支:继承远程历史,但保留工作区文件(--mixed 不碰文件)
|
||
git(["reset", "--mixed", f"origin/{branch}"], work)
|
||
return "adopted-remote"
|
||
if from_git:
|
||
res = git(["rebase", f"origin/{branch}"], work, check=False)
|
||
if res.returncode != 0:
|
||
git(["rebase", "--abort"], work, check=False)
|
||
raise PushError(
|
||
"rebase 冲突,请手动解决后重试:\n" + res.stderr.strip()[:500], code=3)
|
||
return "rebased"
|
||
return "kept-local"
|
||
|
||
|
||
# ─────────────────────────── 提交 & 推送 ───────────────────────────
|
||
|
||
def commit_if_changed(work: Path, message: str, *, add_all: bool = True) -> bool:
|
||
if add_all:
|
||
git(["add", "-A"], work)
|
||
if git(["diff", "--cached", "--quiet"], work, check=False).returncode == 0:
|
||
log(" 无内容变更,跳过 commit")
|
||
return False
|
||
res = git(["commit", "-m", message], work, check=False)
|
||
if res.returncode != 0:
|
||
raise PushError(f"commit 失败:\n{res.stderr.strip() or res.stdout.strip()}")
|
||
log(f" ✓ {git(['log', '--oneline', '-1'], work).stdout.strip()}")
|
||
return True
|
||
|
||
|
||
def push_with_retry(work: Path, branch: str) -> None:
|
||
last = ""
|
||
for attempt in range(1, PUSH_RETRIES + 1):
|
||
res = git(["push", "-u", "origin", branch], work, check=False)
|
||
if res.returncode == 0:
|
||
log(" ✓ 推送成功")
|
||
return
|
||
err = res.stderr.strip()
|
||
last = err
|
||
if any(k in err.lower() for k in ("rejected", "fetch first", "non-fast-forward", "behind")):
|
||
log(f" 推送被拒(第 {attempt} 次),拉取远程后重试…")
|
||
git(["fetch", "origin", branch], work, check=False)
|
||
if remote_branch_exists(work, branch):
|
||
rb = git(["rebase", f"origin/{branch}"], work, check=False)
|
||
if rb.returncode != 0:
|
||
git(["rebase", "--abort"], work, check=False)
|
||
raise PushError("rebase 冲突,无法自动重试:\n" + rb.stderr.strip()[:500],
|
||
code=3)
|
||
continue
|
||
raise PushError(f"推送失败:\n{err[:500]}", code=3)
|
||
raise PushError(f"推送失败(已重试 {PUSH_RETRIES} 次):\n{last[:500]}", code=3)
|
||
|
||
|
||
# ─────────────────────────── 主流程 ───────────────────────────
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
p = argparse.ArgumentParser(
|
||
prog="gitea_push.py",
|
||
description="推送到 Gitea 仓库(通用鲁棒版)",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""示例:
|
||
gitea_push.py remind-me "飞书加急提醒" ./skills/remind-me/
|
||
gitea_push.py my-proj "我的项目" --from-git /root/my-proj
|
||
gitea_push.py my-proj "我的项目" ./src --dry-run
|
||
""")
|
||
p.add_argument("repo_name", help="仓库名(kebab-case)")
|
||
p.add_argument("description", help="仓库描述 / 提交信息")
|
||
p.add_argument("paths", nargs="*", help="要推送的文件或目录(--from-git 时可省)")
|
||
p.add_argument("--from-git", metavar="DIR", default=None,
|
||
help="推送一个已有 git 仓库(保留提交历史)")
|
||
p.add_argument("--owner", default=GITEA_USER, help=f"仓库所有者(默认 {GITEA_USER})")
|
||
p.add_argument("--branch", default=DEFAULT_BRANCH, help=f"分支(默认 {DEFAULT_BRANCH})")
|
||
p.add_argument("--message", default=None, help="自定义提交信息(默认用 description)")
|
||
p.add_argument("--private", action="store_true", help="建私有仓库(默认公开)")
|
||
p.add_argument("--exclude", action="append", default=[], metavar="PATTERN",
|
||
help="额外排除(glob,可重复)")
|
||
p.add_argument("--allow-leaks", action="store_true", help="隐私检查命中时仍继续")
|
||
p.add_argument("--no-scan", action="store_true", help="跳过隐私检查")
|
||
p.add_argument("--dry-run", action="store_true", help="只检查与暂存,不建仓不推送")
|
||
p.add_argument("--quiet", action="store_true", help="静默(只输出结果 JSON)")
|
||
return p
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
global QUIET
|
||
args = build_parser().parse_args(argv)
|
||
QUIET = args.quiet
|
||
|
||
branch = args.branch
|
||
message = args.message or f"feat: {args.description}"
|
||
dry = args.dry_run
|
||
total_steps = 4
|
||
|
||
try:
|
||
if shutil.which("git") is None:
|
||
raise PushError("找不到 git")
|
||
if shutil.which("tea") is None and not dry:
|
||
raise PushError("找不到 tea CLI(Gitea 命令行)")
|
||
|
||
# 源路径
|
||
if args.from_git:
|
||
sources = [Path(args.from_git)]
|
||
else:
|
||
if not args.paths:
|
||
raise PushError("请至少指定一个路径,或用 --from-git 指定仓库目录")
|
||
sources = [Path(p) for p in args.paths]
|
||
missing = [str(s) for s in sources if not s.exists()]
|
||
if missing:
|
||
raise PushError("路径不存在: " + ", ".join(missing))
|
||
|
||
# 1. 隐私检查
|
||
step(1, total_steps, "隐私检查…")
|
||
if args.no_scan:
|
||
log(" (已跳过)")
|
||
else:
|
||
if args.from_git:
|
||
scan_targets = [p for p in Path(args.from_git).iterdir()
|
||
if p.name != ".git"]
|
||
else:
|
||
scan_targets = sources
|
||
findings = scan_paths(scan_targets)
|
||
if findings:
|
||
log("")
|
||
for item in findings:
|
||
log(f" ⚠️ {item['file']}")
|
||
for h in item["hits"]:
|
||
log(f" - {h}")
|
||
log("")
|
||
if not args.allow_leaks:
|
||
raise PushError(
|
||
f"发现 {len(findings)} 个文件含疑似隐私,已中止。"
|
||
"确认无误可加 --allow-leaks 继续。", code=2)
|
||
log(" --allow-leaks 已指定,继续推送")
|
||
else:
|
||
log(" ✓ 未发现敏感内容")
|
||
|
||
# 2. 仓库准备
|
||
step(2, total_steps, f"确保仓库 {args.owner}/{args.repo_name}…")
|
||
is_new = ensure_repo(args.repo_name, args.description, args.owner,
|
||
branch, args.private, dry)
|
||
log(" ✓ 新建仓库" if is_new else " ✓ 复用现有仓库")
|
||
|
||
# 3. 工作区 + 对齐远程
|
||
step(3, total_steps, "准备工作区并对齐远程历史…")
|
||
work, is_temp = prepare_worktree(sources, args.repo_name, args.owner,
|
||
branch, Path(args.from_git) if args.from_git else None,
|
||
args.exclude)
|
||
if not args.from_git:
|
||
git(["config", "user.name", GITEA_USER], work)
|
||
git(["config", "user.email", GITEA_EMAIL], work)
|
||
mode = align_with_remote(work, branch, from_git=bool(args.from_git))
|
||
log(f" 远程对齐方式: {mode}")
|
||
|
||
if args.from_git:
|
||
# --from-git:推送仓库已有的提交,不自动提交未完成的工作区改动
|
||
dirty = git(["status", "--porcelain"], work, check=False).stdout.strip()
|
||
if dirty:
|
||
log(" ⚠️ 工作区有未提交改动,不会被推送(请先自行 commit)")
|
||
changed = False
|
||
else:
|
||
# 普通目录推送:全部纳入暂存区(含新增/修改/删除)
|
||
changed = commit_if_changed(work, message, add_all=True)
|
||
|
||
# 4. 推送
|
||
if dry:
|
||
step(4, total_steps, "dry-run:跳过推送")
|
||
log(f" 工作区: {work}")
|
||
else:
|
||
step(4, total_steps, f"推送到 {args.owner}/{args.repo_name} ({branch})…")
|
||
if args.from_git:
|
||
ahead = git(["rev-list", "--count", f"origin/{branch}..HEAD"],
|
||
work, check=False).stdout.strip()
|
||
if ahead in ("", "0"):
|
||
log(" 本地无新提交,远程已是最新")
|
||
else:
|
||
log(f" 本地领先远程 {ahead} 个提交")
|
||
push_with_retry(work, branch)
|
||
else:
|
||
push_with_retry(work, branch)
|
||
|
||
browse = f"{GITEA_URL}/{args.owner}/{args.repo_name}"
|
||
log("")
|
||
log(f"✅ 完成!浏览: {browse}")
|
||
print(json.dumps({
|
||
"ok": True,
|
||
"repo": args.repo_name,
|
||
"owner": args.owner,
|
||
"url": browse,
|
||
"clone": f"git@{GITEA_SSH_HOST}:{args.owner}/{args.repo_name}.git",
|
||
"branch": branch,
|
||
"new_repo": is_new,
|
||
"dry_run": dry,
|
||
"work_dir": str(work) if is_temp else None,
|
||
"from_git": bool(args.from_git),
|
||
}, ensure_ascii=False))
|
||
return 0
|
||
|
||
except PushError as exc:
|
||
log(f"\n❌ {exc}")
|
||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
||
return exc.code
|
||
except KeyboardInterrupt:
|
||
log("\n已中断")
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|