#!/usr/bin/env python3 # ============================================================ # GiteaHubSync — Gitea → GitHub 镜像同步守护进程 + 控制面板 # 纯 Python 标准库。随 fnOS 应用安装,由 cmd/main 以 # setsid nohup 方式拉起;数据目录 = TRIM_PKGVAR(@appdata/GiteaHubSync) # 页面: / 控制面板(开关/立即同步/快捷入口) # /status 项目状态(每个仓库的详细同步状态) # /manage 项目管理(从 Gitea 快捷添加/移除同步项目) # /settings 设置(GitHub 多账号/切换用户、Gitea 用户名、自动创建仓库) # 多账号: accounts.json 保存多个 GitHub 账号(用户名 + Personal Access Token), # 可随时切换「当前账号」;每个账号独立记录同步进度。带 Token 的账号走 # HTTPS 推送并支持自动创建 GitHub 仓库;无 Token 的旧账号走 SSH 私钥推送。 # ============================================================ import atexit import json import os import re import signal import subprocess import threading import time import uuid from concurrent.futures import ThreadPoolExecutor from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import quote, urlsplit APP_DIR = Path(__file__).resolve().parent.parent DATA_DIR = Path(os.environ.get("GHS_DATA_DIR", "/vol1/@appdata/GiteaHubSync")) REPO_DIR = DATA_DIR / "repos" STATE_DIR = DATA_DIR / ".last-pushed" LOG_FILE = DATA_DIR / "sync.log" PID_FILE = DATA_DIR / "daemon.pid" PANEL_STATE = DATA_DIR / "state.json" REPOS_FILE = DATA_DIR / "repos.json" STATS_FILE = DATA_DIR / "stats.json" ACCOUNTS_FILE = DATA_DIR / "accounts.json" # GitHub 多账号(含 Token,权限 600) SETTINGS_FILE = DATA_DIR / "settings.json" # Gitea 用户名/密码、自动创建开关 GITCREDS_FILE = DATA_DIR / ".gitcreds" # git credential store(Gitea 密码,权限 600) SSH_KEY = APP_DIR / "ssh" / "id_ed25519" if (DATA_DIR / "id_ed25519").exists(): SSH_KEY = DATA_DIR / "id_ed25519" KNOWN_HOSTS = DATA_DIR / "known_hosts" GITEA_URL_ENV = os.environ.get("GITEA_URL") # “打开 Gitea”按钮地址覆盖,一般不设 DEFAULT_GITEA_HOST = "http://127.0.0.1:3000" # 同步引擎访问的 Gitea 地址(设置页可改) DEFAULT_REPOS = ["SMB_Android", "DCU_APP", "GD32E50X_BOOT", "DCU_DesktopController", "Motor_v2", "DCU_ECAT_Master", "GD32_BOOT"] LEGACY_GH_USER = "cbb2625274797" # 旧版硬编码账号,仅用于首次运行的迁移 LEGACY_GITEA_USER = "cbb" # 旧版硬编码的 Gitea 用户名,仅作默认值 DEFAULT_INTERVAL = 10 # 同步间隔默认值(秒) MIN_INTERVAL = 5 # 最小同步间隔(秒) MAX_INTERVAL = 86400 # 最大同步间隔(秒,1 天) DEFAULT_WORKERS = 3 # 同时同步的项目数默认值 MIN_WORKERS = 1 MAX_WORKERS = 8 RETRY_INTERVAL = 60 # 同步失败后的自动重试间隔(秒) DEFAULT_MAX_RETRIES = 5 # 失败重试次数默认值 MIN_MAX_RETRIES = 0 # 0 = 不重试 MAX_MAX_RETRIES = 20 PANEL_PORT = int(os.environ.get("PANEL_PORT", "8899")) START_TS = time.time() NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,100}$") ENV = dict(os.environ) ENV["GIT_SSH_COMMAND"] = (f"ssh -i {SSH_KEY} -o IdentitiesOnly=yes " f"-o StrictHostKeyChecking=accept-new " f"-o UserKnownHostsFile={KNOWN_HOSTS}") ENV["GIT_TERMINAL_PROMPT"] = "0" # ---------------- 应用设置(settings.json) ---------------- cfg = {"gitea_user": LEGACY_GITEA_USER, "gitea_pass": "", "auto_create": True, "interval": DEFAULT_INTERVAL, "gitea_host": DEFAULT_GITEA_HOST, "max_workers": DEFAULT_WORKERS, "max_retries": DEFAULT_MAX_RETRIES, "verify_github": False, # True=每轮都向 GitHub 核对分支/提交号(严格校验) "open_url": ""} # 「打开 Gitea」按钮专用链接(留空则用 gitea_host + 自动推断) def norm_gitea_host(h): """规范化 Gitea 地址:补 http://、去尾部斜杠;不合法返回 None""" h = str(h or "").strip().rstrip("/") if not h: return None if "://" not in h: h = "http://" + h try: p = urlsplit(h) host = p.hostname or "" if p.scheme in ("http", "https") and host \ and not p.path.strip("/") and not p.query and not p.fragment \ and re.fullmatch(r"[A-Za-z0-9.\-:\[\]]+", host): return h except Exception: pass return None def gitea_host(): return cfg.get("gitea_host") or DEFAULT_GITEA_HOST def gitea_ui(host_header="", referer="", xfwd_host=""): """「打开 Gitea」按钮地址。 优先级:环境变量 GITEA_URL > 配置 open_url > gitea_host(自动推断替换)。 自动推断顺序:X-Forwarded-Host > Host > Referer,仅当真实主机非 loopback 时替换 gitea_host。""" env_url = GITEA_URL_ENV if env_url: return env_url ou = (cfg.get("open_url") or "").strip() if ou: return ou base = gitea_host() real = (xfwd_host or host_header or "").split(":")[0].strip() if not real and referer: m = re.match(r'^https?://([^/:/?#]+)', referer) if m: real = m.group(1) if not real or real in ("127.0.0.1", "localhost", "0.0.0.0", "::1"): return base try: p = urlsplit(base) if p.hostname in ("127.0.0.1", "localhost", "0.0.0.0", "::1", ""): return f"{p.scheme}://{real}" + (f":{p.port}" if p.port else "") except Exception: pass return base def sync_interval(): """当前同步间隔(秒),始终返回合法值""" try: iv = int(cfg.get("interval", DEFAULT_INTERVAL)) except Exception: iv = DEFAULT_INTERVAL return min(MAX_INTERVAL, max(MIN_INTERVAL, iv)) def valid_interval(v): try: return MIN_INTERVAL <= int(v) <= MAX_INTERVAL except Exception: return False def sync_workers(): """同时同步的项目数,始终返回合法值""" try: n = int(cfg.get("max_workers", DEFAULT_WORKERS)) except Exception: n = DEFAULT_WORKERS return min(MAX_WORKERS, max(MIN_WORKERS, n)) def valid_workers(v): try: return MIN_WORKERS <= int(v) <= MAX_WORKERS except Exception: return False def max_retries(): """失败自动重试次数,始终返回合法值""" try: n = int(cfg.get("max_retries", DEFAULT_MAX_RETRIES)) except Exception: n = DEFAULT_MAX_RETRIES return min(MAX_MAX_RETRIES, max(MIN_MAX_RETRIES, n)) def valid_retries(v): try: return MIN_MAX_RETRIES <= int(v) <= MAX_MAX_RETRIES except Exception: return False def verify_strict(): """是否每轮都向 GitHub 核对实况(False=仅本地有变化时才核对,省流量)""" return bool(cfg.get("verify_github", False)) def load_settings(): try: d = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) if isinstance(d.get("gitea_user"), str) and valid_name(d["gitea_user"]): cfg["gitea_user"] = d["gitea_user"] cfg["gitea_pass"] = str(d.get("gitea_pass") or "") cfg["auto_create"] = bool(d.get("auto_create", True)) if valid_interval(d.get("interval")): cfg["interval"] = int(d["interval"]) if valid_workers(d.get("max_workers")): cfg["max_workers"] = int(d["max_workers"]) if valid_retries(d.get("max_retries")): cfg["max_retries"] = int(d["max_retries"]) cfg["verify_github"] = bool(d.get("verify_github", False)) h = norm_gitea_host(d.get("gitea_host")) if h: cfg["gitea_host"] = h # open_url 校验:必须是 http(s) URL 且无路径 ou = d.get("open_url") if isinstance(ou, str): cfg["open_url"] = norm_gitea_host(ou.strip()) or "" else: cfg["open_url"] = str(ou or "").strip() or cfg.get("open_url", "") except Exception: pass write_gitcreds() def save_settings(): try: SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) SETTINGS_FILE.write_text(json.dumps(cfg, ensure_ascii=False, indent=1), encoding="utf-8") os.chmod(SETTINGS_FILE, 0o600) # 含 Gitea 密码,仅属主可读 except Exception as e: log(f"保存 settings.json 失败: {e}") write_gitcreds() def gitea_base(): """Gitea 上当前用户的仓库前缀(地址可在设置页修改)""" return f"{gitea_host()}/{cfg['gitea_user']}" # ---------------- GitHub 多账号(accounts.json) ---------------- accounts = [] # [{id,label,gh_user,token,private,created}] active_id = None def load_accounts(): """加载账号;首次运行从旧版迁移:生成「默认(SSH 私钥)」账号,行为与旧版一致""" global accounts, active_id try: d = json.loads(ACCOUNTS_FILE.read_text(encoding="utf-8")) accounts = [a for a in d.get("accounts", []) if a.get("id") and a.get("gh_user")] active_id = d.get("active") except Exception: accounts = [] if not accounts: accounts = [{"id": "default", "label": "默认(SSH 私钥)", "gh_user": LEGACY_GH_USER, "token": "", "private": True, "created": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}] active_id = "default" migrate_flat_state() save_accounts() if active_id not in [a["id"] for a in accounts]: active_id = accounts[0]["id"] save_accounts() def save_accounts(): try: ACCOUNTS_FILE.parent.mkdir(parents=True, exist_ok=True) ACCOUNTS_FILE.write_text( json.dumps({"active": active_id, "accounts": accounts}, ensure_ascii=False, indent=1), encoding="utf-8") os.chmod(ACCOUNTS_FILE, 0o600) # 含 Token,仅属主可读 except Exception as e: log(f"保存 accounts.json 失败: {e}") def active_account(): for a in accounts: if a["id"] == active_id: return a return accounts[0] if accounts else None def find_account(aid): for a in accounts: if a["id"] == aid: return a return None def migrate_flat_state(): """旧版同步状态平铺在 STATE_DIR/;新版按账号分目录 STATE_DIR//""" try: legacy_dir = STATE_DIR / "default" legacy_dir.mkdir(parents=True, exist_ok=True) for f in STATE_DIR.iterdir(): if f.is_file() and valid_name(f.name): f.rename(legacy_dir / f.name) except Exception: pass def state_path(acc, name): return STATE_DIR / ((acc or {}).get("id") or "default") / name def mask_token(tok): if not tok: return "" if len(tok) <= 8: return "****" return tok[:7] + "****" + tok[-4:] def scrub(s, acc): """日志/报错脱敏:GitHub Token 和 Gitea 密码不进日志""" if not s: return s if acc and acc.get("token"): s = s.replace(acc["token"], "***") if cfg.get("gitea_pass"): s = s.replace(cfg["gitea_pass"], "***") return s state = {"enabled": True, "syncing": False, "last_check": None, "checks": 0, "last_result": "尚未运行", "loop_last": None} stats = {"total_checks": 0, "total_pushes": 0, "total_failures": 0, "total_retries": 0, "total_skipped": 0} op_lock = threading.Lock() # 整轮同步互斥(同一轮只跑一次) stats_lock = threading.Lock() # 保护 stats 计数 log_lock = threading.Lock() # 保护日志文件写入 repo_busy_lock = threading.Lock() # 保护 repo_in_progress repo_in_progress = set() # 正在同步的仓库名(按仓库防重入) compare_lock = threading.Lock() # 保护 compare_state compare_state = {} # {仓库名: {verdict, detail, at}} 最近一次 GitHub 比对结果 # ---------------- 基础工具 ---------------- def log(msg): with log_lock: try: DATA_DIR.mkdir(parents=True, exist_ok=True) with open(LOG_FILE, "a", encoding="utf-8") as f: f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n") except Exception: pass def bump_stats(**kw): """线程安全地累加统计计数""" with stats_lock: for k, v in kw.items(): stats[k] = stats.get(k, 0) + v save_stats() def write_gitcreds(): """把 Gitea 凭据写成 git credential store 文件(600),供 clone/fetch 读取; 这样镜像仓库的 remote 地址里不保存密码。清除密码时删除该文件。""" try: if cfg.get("gitea_pass"): p = urlsplit(gitea_host()) netloc = p.hostname + (f":{p.port}" if p.port else "") user = quote(str(cfg["gitea_user"]), safe="") pw = quote(str(cfg["gitea_pass"]), safe="") GITCREDS_FILE.write_text(f"{p.scheme}://{user}:{pw}@{netloc}\n", encoding="utf-8") os.chmod(GITCREDS_FILE, 0o600) elif GITCREDS_FILE.exists(): GITCREDS_FILE.unlink() except Exception: pass def repoint_mirror_remotes(): """Gitea 地址变更后,把已有镜像仓库的 origin 指向新地址""" n = 0 try: for repo in REPO_DIR.glob("*.git"): r = git(["-C", str(repo), "remote", "set-url", "origin", f"{gitea_base()}/{repo.name[:-4]}.git"], timeout=20) if r.returncode == 0: n += 1 except Exception as e: log(f"更新镜像 remote 失败: {e}") return n def git_creds_args(): if cfg.get("gitea_pass") and GITCREDS_FILE.exists(): return ["-c", f"credential.helper=store --file={GITCREDS_FILE}"] return [] def git(args, timeout=300): return subprocess.run(["git"] + git_creds_args() + args, capture_output=True, text=True, env=ENV, timeout=timeout) def load_stats(): global stats try: stats.update(json.loads(STATS_FILE.read_text(encoding="utf-8"))) except Exception: pass def save_stats(): try: STATS_FILE.write_text(json.dumps(stats), encoding="utf-8") except Exception: pass def load_repos(): """同步项目列表(repos.json),首次运行播种默认 7 个""" try: data = json.loads(REPOS_FILE.read_text(encoding="utf-8")) repos = [r for r in data.get("repos", []) if valid_name(r)] if repos: return repos except Exception: pass save_repos(DEFAULT_REPOS) return list(DEFAULT_REPOS) def save_repos(repos): try: DATA_DIR.mkdir(parents=True, exist_ok=True) REPOS_FILE.write_text(json.dumps({"repos": repos}, ensure_ascii=False, indent=1), encoding="utf-8") except Exception as e: log(f"保存 repos.json 失败: {e}") def valid_name(name): return bool(name) and bool(NAME_RE.match(name)) and name not in (".", "..") def gitea_api(path): """访问本地 Gitea API(配置了密码则带 Basic Auth,可读私有仓库),返回 (ok, json)""" try: cmd = ["curl", "-sS", "-m", "10"] if cfg.get("gitea_pass"): cmd += ["-u", f"{cfg['gitea_user']}:{cfg['gitea_pass']}"] cmd.append(f"{gitea_host()}/api/v1{path}") r = subprocess.run(cmd, capture_output=True, text=True, timeout=15) return r.returncode == 0, json.loads(r.stdout or "{}") except Exception: return False, {} def gitea_probe(host=None): """探测 Gitea 地址连通性,返回 (ok, msg)""" host = host or gitea_host() try: r = subprocess.run(["curl", "-sS", "-m", "8", "-w", "\n%{http_code}", f"{host}/api/v1/version"], capture_output=True, text=True, timeout=12) out, _, code = r.stdout.rpartition("\n") st = int(code) if code.strip().isdigit() else 0 if 200 <= st < 300: try: ver = json.loads(out or "{}").get("version", "?") except Exception: ver = "?" return True, f"连接成功,Gitea 版本 {ver}" return False, f"地址连不上(HTTP {st or '超时'}),请检查后重试" except Exception as e: return False, f"地址连不上:{e}" def gitea_repo_exists(name): ok, d = gitea_api(f"/repos/{cfg['gitea_user']}/{name}") return ok and isinstance(d, dict) and d.get("full_name") == f"{cfg['gitea_user']}/{name}" def gh_api(acc, path, method="GET", body=None, timeout=15): """调用 GitHub API(需 Token)。返回 (ok, http_status, json)""" if not acc or not acc.get("token"): return False, 0, {} cmd = ["curl", "-sS", "-m", str(timeout), "-X", method, "-H", f"Authorization: token {acc['token']}", "-H", "Accept: application/vnd.github+json", "-H", "Content-Type: application/json", "-w", "\n%{http_code}"] if body is not None: cmd += ["-d", json.dumps(body, ensure_ascii=False)] cmd.append(f"https://api.github.com{path}") try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5) out, _, code = r.stdout.rpartition("\n") status = int(code) if code.strip().isdigit() else 0 try: d = json.loads(out or "{}") except Exception: d = {} return (200 <= status < 300), status, d except Exception: return False, 0, {} def gh_verify_token(token): """验证 Token 有效性,返回 (ok, login, msg)""" token = (token or "").strip() if not token: return False, "", "Token 不能为空" try: r = subprocess.run( ["curl", "-sS", "-m", "15", "-w", "\n%{http_code}", "-H", f"Authorization: token {token}", "-H", "Accept: application/vnd.github+json", "https://api.github.com/user"], capture_output=True, text=True, timeout=20) out, _, code = r.stdout.rpartition("\n") status = int(code) if code.strip().isdigit() else 0 if 200 <= status < 300: d = json.loads(out or "{}") return True, (d.get("login") or ""), "" if status == 401: return False, "", "Token 无效或已过期(请重新生成)" return False, "", f"GitHub API 返回 HTTP {status}" except Exception as e: return False, "", f"网络错误: {e}" def gh_push_url(acc, name): """当前账号的 GitHub 推送地址:有 Token 走 HTTPS,否则回退 SSH 私钥""" if acc.get("token"): return f"https://x-access-token:{acc['token']}@github.com/{acc['gh_user']}/{name}.git" return f"git@github.com:{acc['gh_user']}/{name}.git" def github_repo_exists(acc, name): if acc.get("token"): ok, status, d = gh_api(acc, f"/repos/{acc['gh_user']}/{name}") return ok and status == 200 and isinstance(d, dict) \ and d.get("full_name") == f"{acc['gh_user']}/{name}" try: r = git(["ls-remote", gh_push_url(acc, name), "HEAD"], timeout=30) return r.returncode == 0 except Exception: return False def github_create_repo(acc, name): """在 GitHub 自动创建仓库(需 Token)。返回 (ok, msg)""" if not acc.get("token"): return False, "当前账号未配置 Token,无法自动创建(请到设置页添加带 Token 的 GitHub 账号)" ok, status, d = gh_api(acc, "/user/repos", "POST", { "name": name, "private": bool(acc.get("private", True)), "description": f"镜像自飞牛 Gitea 仓库 {name}(GiteaHubSync 自动创建)"}) if ok and status == 201: return True, "已创建" if status == 422 and isinstance(d, dict) and "errors" in d: return True, "已存在" # 重名等情况,按已存在继续推送 msg = d.get("message") if isinstance(d, dict) else str(d) return False, f"HTTP {status} {msg}" def list_gitea_repos(): ok, d = gitea_api(f"/users/{cfg['gitea_user']}/repos?limit=100") if ok and isinstance(d, list): return [x.get("name") for x in d if x.get("name")] return [] # ---------------- 同步逻辑 ---------------- MIRROR_NS = ("refs/heads/", "refs/tags/", "refs/notes/") # 参与比对的引用命名空间 def _parse_refs(text): """把 '\t' 文本行解析成 {ref: sha};只保留镜像命名空间,去掉 tag 的 ^{} 解引用行""" out = {} for line in (text or "").splitlines(): line = line.strip() if not line or "\t" not in line: continue sha, ref = line.split("\t", 1) ref = ref.strip() if ref.endswith("^{}"): continue if not ref.startswith(MIRROR_NS): continue out[ref] = sha.strip() return out def local_refs(repo): """本地镜像仓库的分支/标签/notes 及其提交号""" r = git(["-C", str(repo), "for-each-ref", "--format=%(objectname)\t%(refname)"], timeout=60) if r.returncode != 0: return None return _parse_refs(r.stdout) def remote_refs(acc, name): """GitHub 上该仓库真实的分支/标签/提交号(ls-remote)。 返回 {ref: sha};空仓库返回 {};无法访问返回 None。""" try: r = git(["ls-remote", "--refs", gh_push_url(acc, name)], timeout=60) if r.returncode != 0: return None return _parse_refs(r.stdout) except Exception: return None def compare_remote(acc, name, repo): """比对 GitHub 与本地镜像。返回 ('same'|'diff'|'unknown', 说明)""" rem = remote_refs(acc, name) if rem is None: return "unknown", "无法读取 GitHub 上的引用" loc = local_refs(repo) if loc is None: return "unknown", "无法读取本地引用" if rem == loc: return "same", f"{len(loc)} 个分支/标签一致" only_remote = sorted(set(rem) - set(loc)) only_local = sorted(set(loc) - set(rem)) moved = sorted(k for k in (set(rem) & set(loc)) if rem[k] != loc[k]) bits = [] if only_local: bits.append(f"GitHub 缺 {len(only_local)} 个(如 {only_local[0]})") if only_remote: bits.append(f"GitHub 多 {len(only_remote)} 个(如 {only_remote[0]})") if moved: bits.append(f"{len(moved)} 个提交号不同(如 {moved[0]})") return "diff", ";".join(bits) or "内容不同" def note_compare(name, verdict, detail): """记录最近一次比对结果,供前端展示""" with compare_lock: compare_state[name] = {"verdict": verdict, "detail": detail, "at": datetime.now().strftime("%m-%d %H:%M:%S")} def compare_snapshot(): with compare_lock: return dict(compare_state) def fingerprint(repo): r = git(["-C", str(repo), "for-each-ref", "--format=%(refname) %(objectname)"]) if r.returncode != 0: return None import hashlib return hashlib.sha1(r.stdout.encode()).hexdigest() def sync_repo(name): """返回 'changed' / 'same' / 'busy' / 'error:...';同一仓库并发调用时后到者返回 busy""" if not valid_name(name): return "error: 非法仓库名" acc = active_account() if not acc: return "error: 没有可用的 GitHub 账号,请到设置页添加" with repo_busy_lock: if name in repo_in_progress: return "busy" repo_in_progress.add(name) try: return _sync_repo_locked(name, acc) finally: with repo_busy_lock: repo_in_progress.discard(name) def _sync_repo_locked(name, acc): repo = REPO_DIR / f"{name}.git" try: if not repo.exists(): REPO_DIR.mkdir(parents=True, exist_ok=True) r = git(["clone", "--quiet", "--mirror", f"{gitea_base()}/{name}.git", str(repo)]) if r.returncode != 0: return "error: 克隆失败 " + scrub((r.stderr or "")[:120], acc) log(f"{name}: 首次克隆完成") r = git(["-C", str(repo), "fetch", "--quiet", "--prune", "origin"]) if r.returncode != 0: return "error: fetch 失败 " + scrub((r.stderr or "")[:120], acc) cur = fingerprint(repo) if cur is None: return "error: 读取 refs 失败" st = state_path(acc, name) prev = st.read_text().strip() if st.exists() else "" locally_changed = (cur != prev) # 本地没变化时默认不打扰 GitHub(快);严格模式下每轮都核对 GitHub 实况 if not locally_changed and not verify_strict(): note_compare(name, "same", "本地无新提交,未访问 GitHub") return "same" # 推送前先看 GitHub 上真实的分支/提交号,一致就不推(省一次大流量推送) if not github_repo_exists(acc, name): if acc.get("token") and cfg.get("auto_create", True): ok, msg = github_create_repo(acc, name) if not ok: return f"error: 自动创建 GitHub 仓库失败: {msg}" log(f"{name}: GitHub 仓库不存在,已自动创建" f"({'私有' if acc.get('private', True) else '公开'})") else: hint = ("当前账号无 Token 或未开启自动创建,请到设置页配置" if not acc.get("token") else "自动创建已关闭,请到设置页开启或在 GitHub 手动创建") return f"error: GitHub 上不存在 {acc['gh_user']}/{name},{hint}" verdict, detail = compare_remote(acc, name, repo) note_compare(name, verdict, detail) if verdict == "same": # GitHub 与本地已完全一致:只更新同步记录,跳过推送 st.parent.mkdir(parents=True, exist_ok=True) st.write_text(cur) if locally_changed: log(f"{name}: GitHub 上分支/提交号已与本地一致,跳过推送({detail})") bump_stats(total_skipped=1) return "same" if verdict == "diff": log(f"{name}: GitHub 与本地不一致 → 需要推送({detail})") p = git(["-C", str(repo), "push", "--mirror", gh_push_url(acc, name)]) if p.returncode != 0: return "error: 推送失败 " + scrub((p.stderr or p.stdout or "")[:120], acc) st.parent.mkdir(parents=True, exist_ok=True) st.write_text(cur) detail2 = " ".join(l.strip() for l in p.stdout.splitlines() if any(k in l for k in ("new branch", "new tag", "deleted", "forced", "->"))) # 推送后再核对一次,确认 GitHub 真的和本地一致(防止推送被拒但返回 0 的怪情况) v2, d2 = compare_remote(acc, name, repo) note_compare(name, v2, d2 if v2 != "same" else "推送后核对一致") log(f"{name}: 已同步到 GitHub({acc['gh_user']})" + (f" | {scrub(detail2, acc)}" if detail2 else "")) if v2 == "diff": log(f"{name}: 警告:推送后 GitHub 仍与本地不一致({d2})") return "changed" except Exception as e: return f"error: {scrub(str(e), acc)}" def run_sync(repos=None): """跑一轮同步:多个项目按设置并行(默认 3 个同时)""" with op_lock: if state["syncing"]: return False, "已有同步任务正在进行" state["syncing"] = True try: repos = repos if repos is not None else load_repos() results = {} workers = sync_workers() def worker(name): try: results[name] = sync_repo(name) except Exception as e: results[name] = f"error: {e}" if repos: with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="ghs-sync") as ex: # map 会阻塞到全部完成;异常已在 worker 内捕获 list(ex.map(worker, repos)) errs, changed = [], 0 for r in repos: res = results.get(r, "error: 未执行") if res.startswith("error"): errs.append(f"{r} {res}") elif res == "changed": changed += 1 # 'same' / 'busy' 不计失败 failed_names = [r for r in repos if results.get(r, "").startswith("error")] with retry_lock: for r in repos: if r not in failed_names: retry_queue.pop(r, None) # 成功的仓库退出重试队列 schedule_retries(failed_names) if errs: bump_stats(total_failures=len(errs)) if changed: bump_stats(total_pushes=changed) state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") state["checks"] += 1 bump_stats(total_checks=1) if errs: state["last_result"] = "失败: " + "; ".join(errs)[:200] return False, state["last_result"] state["last_result"] = "成功" return True, ("成功" if changed == 0 else f"成功(同步了 {changed} 个仓库)") except Exception as e: state["last_result"] = f"异常: {e}" return False, state["last_result"] finally: state["syncing"] = False # ---------------- 失败自动重试(独立于同步间隔,默认每 60s 一次) ---------------- retry_lock = threading.Lock() retry_queue = {} # {仓库名: 已重试次数} retry_next_at = 0.0 # 下次重试时间戳 def clear_retry(name): with retry_lock: retry_queue.pop(name, None) def schedule_retries(failed): """整轮同步有失败时,把失败仓库排入自动重试(每轮重新计数)""" global retry_next_at mr = max_retries() if mr <= 0 or not failed: return with retry_lock: for name in failed: retry_queue.setdefault(name, 0) retry_next_at = time.time() + RETRY_INTERVAL log(f"同步失败,已排程自动重试: {', '.join(failed)}" f"(每 {RETRY_INTERVAL} 秒一次,最多 {mr} 次)") def retry_snapshot(): """重试状态(供前端展示)""" with retry_lock: if not retry_queue: return {"pending": [], "attempts": {}, "max": max_retries(), "next_in": None} return {"pending": sorted(retry_queue.keys()), "attempts": dict(retry_queue), "max": max_retries(), "next_in": max(0, int(retry_next_at - time.time()))} def retry_round(): """跑一轮重试:并行重试队列中的仓库;成功移出,busy 不计数,失败计数+1,超上限放弃""" global retry_next_at with retry_lock: batch = dict(retry_queue) if not batch: return results = {} workers = max(1, min(sync_workers(), len(batch))) def worker(name): try: results[name] = sync_repo(name) except Exception as e: results[name] = f"error: {e}" with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="ghs-retry") as ex: list(ex.map(worker, batch)) mr = max_retries() ok_names, failed, busy, gave_up = [], [], [], [] with retry_lock: for name, res in results.items(): if res in ("changed", "same"): retry_queue.pop(name, None) ok_names.append(name) elif res == "busy": busy.append(name) # 被其他同步占用,不计数 else: n = batch.get(name, 0) + 1 if n >= mr: retry_queue.pop(name, None) gave_up.append((name, n)) else: retry_queue[name] = n failed.append((name, n)) attempts = len(results) - len(busy) if retry_queue: retry_next_at = time.time() + RETRY_INTERVAL if attempts: bump_stats(total_retries=attempts) if ok_names: log(f"重试成功: {', '.join(ok_names)}") for name, n in failed: log(f"重试失败: {name}(第 {n}/{mr} 次,{RETRY_INTERVAL} 秒后再试)") for name, n in gave_up: log(f"重试 {n} 次仍失败,放弃自动重试: {name}(下一轮同步或手动同步会再次尝试)") def retry_loop(): while True: time.sleep(2) try: with retry_lock: due = bool(retry_queue) and time.time() >= retry_next_at if due: retry_round() except Exception as e: log(f"重试线程异常: {e}") def loop(): while True: state["loop_last"] = time.time() if state["enabled"]: run_sync() time.sleep(sync_interval()) def load_panel_state(): try: state["enabled"] = bool(json.loads(PANEL_STATE.read_text(encoding="utf-8")).get("enabled", True)) except Exception: pass def save_panel_state(): try: PANEL_STATE.write_text(json.dumps({"enabled": state["enabled"]}, ensure_ascii=False), encoding="utf-8") except Exception: pass # ---------------- 状态详情 ---------------- def repo_detail(name): repo = REPO_DIR / f"{name}.git" st = state_path(active_account(), name) d = {"name": name, "cloned": repo.exists(), "branches": 0, "tags": 0, "head": None, "last_commit": None, "size_mb": 0.0, "status": "未同步", "last_push": None, "compare": None} cmp = compare_snapshot().get(name) if cmp: d["compare"] = cmp if st.exists(): d["status"] = "已同步" d["last_push"] = datetime.fromtimestamp(st.stat().st_mtime).strftime("%m-%d %H:%M:%S") if not repo.exists(): return d try: r = git(["-C", str(repo), "for-each-ref", "--format=%(refname)"], timeout=20) if r.returncode == 0: refs = r.stdout.split() d["branches"] = sum(1 for x in refs if x.startswith("refs/heads/")) d["tags"] = sum(1 for x in refs if x.startswith("refs/tags/")) h = git(["-C", str(repo), "symbolic-ref", "--quiet", "HEAD"], timeout=10) if h.returncode == 0 and h.stdout.strip(): d["head"] = h.stdout.strip().replace("refs/heads/", "") if d["head"]: c = git(["-C", str(repo), "log", "-1", "--format=%h\t%s\t%an\t%ad", "--date=format:%m-%d %H:%M", d["head"]], timeout=10) if c.returncode == 0 and c.stdout.strip(): p = c.stdout.strip().split("\t", 3) d["last_commit"] = {"hash": p[0], "msg": p[1] if len(p) > 1 else "", "author": p[2] if len(p) > 2 else "", "time": p[3] if len(p) > 3 else ""} d["size_mb"] = round(sum(f.stat().st_size for f in repo.rglob("*") if f.is_file()) / 1048576, 1) except Exception: pass return d def fmt_uptime(sec): sec = int(sec) d, rem = divmod(sec, 86400) h, rem = divmod(rem, 3600) m, s = divmod(rem, 60) return (f"{d}天 " if d else "") + f"{h:02d}:{m:02d}:{s:02d}" def account_public(acc): """账号信息(脱敏后可下发到前端)""" if not acc: return None return {"id": acc["id"], "label": acc.get("label") or acc["gh_user"], "gh_user": acc["gh_user"], "token": mask_token(acc.get("token")), "has_token": bool(acc.get("token")), "private": bool(acc.get("private", True)), "method": "Token(HTTPS)" if acc.get("token") else "SSH 私钥"} def global_status(ui_url=None): repos = load_repos() details = [repo_detail(r) for r in repos] now = time.time() next_in = None if state["enabled"] and state["loop_last"]: next_in = max(0, int(state["loop_last"] + sync_interval() - now)) gitea_all = list_gitea_repos() acc = active_account() return { "enabled": state["enabled"], "syncing": state["syncing"], "uptime": fmt_uptime(now - START_TS), "interval": sync_interval(), "max_workers": sync_workers(), "retry": retry_snapshot(), "verify_github": verify_strict(), "last_check": state["last_check"], "next_in": next_in, "checks": state["checks"], "stats": stats, "last_result": state["last_result"], "repo_count": len(repos), "synced_count": sum(1 for x in details if x["status"] == "已同步"), "total_size_mb": round(sum(x["size_mb"] for x in details), 1), "repos": details, "gitea_url": ui_url or gitea_ui(), "gitea_user": cfg["gitea_user"], "gitea_online": bool(gitea_all), "available": [r for r in gitea_all if r not in repos], "account": account_public(acc), "auto_create": bool(cfg.get("auto_create", True)), "gh_url": f"https://github.com/{acc['gh_user']}?tab=repositories" if acc else "", } def tail_log(n=80): try: return LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-n:] except Exception: return [] # ---------------- 页面 ---------------- CSS = """ *{box-sizing:border-box;margin:0;padding:0} body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#f0f2f5;color:#1f2329;min-height:100vh} .wrap{width:100%;padding:20px 20px 60px} .top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:14px} .top h1{font-size:20px;display:flex;align-items:center;gap:8px;min-width:0} .pill{font-size:12px;padding:4px 12px;border-radius:999px;font-weight:600;white-space:nowrap;flex:none} .pill.on{background:#e3f5e9;color:#0a7d34}.pill.off{background:#fee;color:#c0392b} nav{display:flex;gap:6px;margin-bottom:16px;background:#fff;border-radius:12px;padding:6px;box-shadow:0 1px 4px rgba(0,0,0,.06);flex-wrap:wrap} nav a{padding:8px 16px;border-radius:8px;text-decoration:none;color:#646a73;font-size:14px;font-weight:600;white-space:nowrap} nav a.active{background:#e8f0fe;color:#1f6feb} .card{background:#fff;border-radius:14px;padding:18px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.06);min-width:0} .row{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:14px} .switchbox{display:flex;align-items:center;gap:14px;min-width:0} .switch{position:relative;width:64px;height:34px;flex:none} .switch input{opacity:0;width:0;height:0} .slider{position:absolute;inset:0;background:#cbd2d9;border-radius:34px;cursor:pointer;transition:.25s} .slider:before{content:"";position:absolute;width:26px;height:26px;left:4px;top:4px;background:#fff;border-radius:50%;transition:.25s;box-shadow:0 1px 3px rgba(0,0,0,.25)} input:checked+.slider{background:#0a7d34} input:checked+.slider:before{transform:translateX(30px)} .swlabel{font-size:16px;font-weight:600}.swsub{font-size:12px;color:#646a73;margin-top:2px} .btns{display:flex;gap:10px;flex-wrap:wrap} button,a.btn{border:none;border-radius:10px;padding:11px 18px;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:.15s;white-space:nowrap} button:active,a.btn:active{transform:scale(.97)} .b-sync{background:#0a7d34;color:#fff}.b-sync:hover{background:#086b2c} .b-gitea{background:#1f6feb;color:#fff}.b-gitea:hover{background:#1a5fd0} .b-gh{background:#24292f;color:#fff}.b-gh:hover{background:#000} .b-add{background:#7c3aed;color:#fff}.b-add:hover{background:#6d28d9} .b-del{background:#fee2e2;color:#b91c1c;padding:6px 12px;font-size:12px} .b-del:hover{background:#fecaca} .b-mini{background:#eef2ff;color:#4338ca;padding:6px 12px;font-size:12px} button:disabled{background:#cbd2d9;color:#fff;cursor:not-allowed} .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px} .stat{background:#f6f8fa;border-radius:10px;padding:12px 14px;min-width:0} .stat .k{font-size:12px;color:#646a73} .stat .v{font-size:15px;font-weight:700;margin-top:4px;word-break:break-word} .tablewrap{overflow-x:auto;-webkit-overflow-scrolling:touch} table{width:100%;border-collapse:collapse;font-size:13px} th,td{padding:9px 8px;text-align:left;border-bottom:1px solid #eef0f3;vertical-align:top} th{color:#646a73;font-weight:600;font-size:12px;white-space:nowrap} td{word-break:break-word} td.ok{color:#0a7d34;font-weight:600;white-space:nowrap} td.warn{color:#b45309;font-weight:600;white-space:nowrap} .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px} .commit{color:#646a73;font-size:12px;margin-top:2px;line-height:1.5} h2{font-size:15px;margin-bottom:12px;color:#1f2329} pre{background:#0d1117;color:#c9d1d9;border-radius:10px;padding:14px;font-size:12px;line-height:1.6;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all} .tip{font-size:12px;color:#646a73;margin-top:10px;line-height:1.6} .addrow{display:flex;gap:10px;flex-wrap:wrap} .addrow input{flex:1;min-width:180px;border:1px solid #d0d5dd;border-radius:10px;padding:11px 14px;font-size:14px;outline:none} .addrow input:focus{border-color:#7c3aed} .avail{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px} .chip{border:1px dashed #7c3aed;color:#7c3aed;border-radius:999px;padding:6px 14px;font-size:13px;cursor:pointer;background:#faf5ff;white-space:nowrap} .chip:hover{background:#f3e8ff} .msg{margin-top:10px;font-size:13px;padding:10px 14px;border-radius:8px;display:none;line-height:1.6;word-break:break-word} .msg.err{display:block;background:#fee;color:#b91c1c} .msg.ok{display:block;background:#e3f5e9;color:#0a7d34} input[type=checkbox]{accent-color:#7c3aed;cursor:pointer} .addrow input[type=password]{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.5px} td .pill{font-size:12px} /* ---------- 设置页 ---------- */ h2 .h2sub{font-size:12px;font-weight:400;color:#8a919e;margin-left:10px} .banner{border-radius:10px;padding:12px 16px;font-size:13px;line-height:1.75;margin-bottom:14px;display:flex;gap:10px;align-items:flex-start} .banner.warn{background:#fffaeb;border:1px solid #fec84b;color:#92400e} .banner .ic{font-size:16px;line-height:1.5;flex:none} .badge{display:inline-block;font-size:12px;font-weight:600;padding:3px 10px;border-radius:999px;white-space:nowrap;line-height:1.5} .badge.b-ok{background:#e3f5e9;color:#0a7d34} .badge.b-warn{background:#fef0c7;color:#b45309} .badge.b-mute{background:#f0f2f5;color:#646a73} .badge.b-info{background:#e8f0fe;color:#1f6feb} .flabel{display:block;font-size:13px;font-weight:600;color:#374151;margin:16px 0 6px} .flabel:first-child{margin-top:0} .flabel .sub{font-weight:400;color:#8a919e;font-size:12px;margin-left:8px} .chk{display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;padding:4px 0} .chk input{width:18px;height:18px} .optbox{background:#f6f8fa;border-radius:10px;padding:6px 16px;margin-top:16px} .optrow{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:12px 0} .optrow+.optrow{border-top:1px solid #eef0f3} .opttext{flex:1;min-width:200px} .opttext .swlabel{font-size:14px;font-weight:600} .opttext .swsub{font-size:12px;color:#646a73;margin-top:2px;line-height:1.6} .numin{flex:none;width:110px;border:1px solid #d0d5dd;border-radius:10px;padding:10px 12px;font-size:14px;outline:none;text-align:center} .numin:focus{border-color:#7c3aed} .numwrap{display:flex;align-items:center;gap:8px;flex:none} .saved-tip{font-size:12px;font-weight:600;white-space:nowrap} .saved-tip.ok{color:#0a7d34} .saved-tip.err{color:#b91c1c} input[readonly]{background:#f6f8fa;color:#646a73;cursor:not-allowed} .b-icon{padding:5px 9px;min-width:auto;font-size:13px} /* ---------- 模态弹窗 ---------- */ .modal{display:none;position:fixed;inset:0;background:rgba(15,23,42,.5);z-index:1000;align-items:center;justify-content:center;padding:24px;backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px)} .modal.show{display:flex} .modal-box{background:#fff;border-radius:16px;width:100%;max-width:520px;box-shadow:0 25px 70px rgba(15,23,42,.35),0 0 0 1px rgba(15,23,42,.04);max-height:90vh;overflow:auto;animation:modalIn .18s ease-out} @keyframes modalIn{from{transform:translateY(-12px) scale(.98);opacity:.5}to{transform:translateY(0) scale(1);opacity:1}} .modal-head{display:flex;align-items:center;gap:14px;padding:20px 24px;border-bottom:1px solid #f0f2f5} .modal-avatar{width:42px;height:42px;border-radius:50%;background:linear-gradient(135deg,#7c3aed,#a855f7);color:#fff;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;flex:none;box-shadow:0 4px 10px rgba(124,58,237,.25)} .modal-head .titles{flex:1;min-width:0} .modal-head h3{font-size:17px;font-weight:700;color:#1f2329;margin:0;line-height:1.3} .modal-head .subtitle{font-size:12px;color:#8a919e;margin-top:2px;line-height:1.4} .modal-close{background:none;border:none;font-size:22px;color:#8a919e;cursor:pointer;padding:6px 10px;border-radius:8px;line-height:1;flex:none;transition:all .15s} .modal-close:hover{background:#f0f2f5;color:#1f2329} .modal-body{padding:22px 24px;display:flex;flex-direction:column;gap:18px} .modal-field{display:flex;flex-direction:column;gap:6px} .modal-field label.mfl{font-size:13px;font-weight:600;color:#374151;display:flex;align-items:center;gap:6px} .modal-field .mfhint{font-size:12px;color:#8a919e;line-height:1.5;margin-top:-2px} .modal-body input[type=text],.modal-body input[type=password]{ border:1px solid #d0d5dd;border-radius:10px;padding:11px 14px;font-size:14px;outline:none;width:100%;box-sizing:border-box;font-family:inherit;background:#fff;color:#1f2329} .modal-body input[type=text]:focus,.modal-body input[type=password]:focus{border-color:#7c3aed} .modal-body input[readonly]{background:#f4f5f7;color:#374151;cursor:default;border-style:dashed} .modal-body input[readonly]:focus{border-color:#d0d5dd} /* 带前缀图标的输入框(保留图标,padding 让出左侧空间) */ .mf-input{position:relative} .mf-input .mf-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:14px;color:#8a919e;pointer-events:none;line-height:1;transition:color .15s} .mf-input input{padding-left:36px} .mf-input input:focus+.mf-icon,.mf-input input:focus~.mf-icon{color:#7c3aed} .mf-input.mf-token input{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.5px} .mf-info{display:flex;align-items:center;gap:10px;padding:10px 14px;background:#f4f5f7;border:1.5px dashed #e0e3e8;border-radius:10px;color:#374151;font-size:14px} .mf-info .mfi-label{font-size:12px;color:#8a919e;font-weight:500;flex:none} .mf-info .mfi-value{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;word-break:break-all} .mf-token-current{display:flex;align-items:center;gap:8px;padding:8px 12px;background:#f4f5f7;border-radius:8px;font-size:12px;color:#646a73;margin-top:-2px} .mf-token-current code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#374151} .mf-token-current .dot{width:6px;height:6px;border-radius:50%;background:#0a7d34;flex:none} .mf-token-current.none .dot{background:#b45309} .mf-token-current.none code{color:#b45309} .mf-token-current .none{color:#b45309;font-weight:500} .mf-row{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#f4f5f7;border-radius:10px;cursor:pointer;transition:background .15s;min-height:42px} .mf-row:hover{background:#ebedf0} .mf-row input[type=checkbox]{width:18px;height:18px;flex:none} .modal-foot{padding:16px 24px;border-top:1px solid #f0f2f5;display:flex;align-items:center;gap:12px;background:#fafbfc;border-radius:0 0 16px 16px} .modal-foot .modal-msg{margin:0} .modal-foot .btn-cancel{background:#fff;border:1.5px solid #e5e8ec;color:#646a73;border-radius:10px;padding:10px 20px;font-size:14px;font-weight:600;cursor:pointer;transition:all .15s} .modal-foot .btn-cancel:hover{background:#f4f5f7;border-color:#c7cdd4;color:#1f2329} .modal-foot .btn-save{background:linear-gradient(135deg,#7c3aed,#a855f7);color:#fff;border:none;border-radius:10px;padding:10px 24px;font-size:14px;font-weight:600;cursor:pointer;transition:all .15s;box-shadow:0 4px 12px rgba(124,58,237,.25);min-width:120px} .modal-foot .btn-save:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 6px 16px rgba(124,58,237,.35)} .modal-foot .btn-save:disabled{opacity:.6;cursor:not-allowed} .modal-msg{font-size:13px;flex:1;min-width:0;word-break:break-word;line-height:1.5} .modal-msg.ok{color:#0a7d34} .modal-msg.err{color:#b91c1c} .modal-msg:empty{display:none} @media (max-width:760px){ .modal-box{max-width:none;border-radius:14px} .modal-head,.modal-body,.modal-foot{padding-left:18px;padding-right:18px} } table.hover tbody tr:hover{background:#f8fafc} td.ops{white-space:nowrap} td.ops button,td.ops .pill{margin:2px 4px 2px 0} /* ---------- 窄屏(手机 / 飞牛桌面小窗口):表格转卡片 ---------- */ @media (max-width:760px){ .wrap{padding:14px 10px 40px} .top h1{font-size:16px} nav{gap:4px} nav a{padding:8px 12px;font-size:13px;flex:1;text-align:center} .stats{grid-template-columns:repeat(2,1fr);gap:8px} .stat{padding:10px} .stat .v{font-size:13px} .card{padding:14px;border-radius:12px} button,a.btn{padding:10px 14px;font-size:13px} .btns{width:100%} .btns button,.btns a.btn{flex:1;justify-content:center} .addrow{flex-direction:column} .addrow input{min-width:0;width:100%} .addrow button{width:100%;justify-content:center} table.resp{min-width:0} table.resp thead{display:none} table.resp tbody tr{display:block;border:1px solid #e5e8ec;border-radius:12px;margin-bottom:10px;padding:10px 12px;background:#fbfcfd} table.resp tbody td{display:flex;justify-content:space-between;align-items:baseline;gap:6px 14px;border:none;padding:5px 0;flex-wrap:wrap} table.resp tbody td::before{content:attr(data-label);color:#646a73;font-size:12px;font-weight:600;flex:none} table.resp tbody td .val{flex:1;text-align:right;min-width:0;word-break:break-word} table.resp tbody td:has(button){justify-content:flex-end} table.resp tbody td:has(button)::before{margin-right:auto} pre{font-size:11px;padding:10px} } """ NAV = """""" def page(title, body, active): nav = (NAV.replace("__A__", 'class="active"' if active == "home" else "") .replace("__B__", 'class="active"' if active == "status" else "") .replace("__C__", 'class="active"' if active == "manage" else "") .replace("__D__", 'class="active"' if active == "settings" else "")) return f""" {title} · GiteaHubSync

🔄 {title}

运行中
{nav} {body}
""" PAGE_HOME = """
自动同步:开
开启后定期检查 Gitea,有新提交自动推送到 GitHub
自动同步
—
GitHub 目标账号
—
同步项目
—
守护运行时长
—
最近实际同步
—

同步日志(最近 80 行)

加载中…
日志文件:__LOGPATH__ · 面板每 3 秒自动刷新
""" PAGE_STATUS = """
同步项目
—
已同步
—
守护运行时长
—
距下次检查
—
累计检查轮数
—
累计实际同步
—
一致跳过推送
—
累计失败次数
—
失败重试
—
镜像总占用
—

项目明细

项目状态默认分支分支/标签最近推送同步GitHub 核对最新提交大小
""" PAGE_MANAGE = """

快捷添加项目

下面可直接点击你 Gitea 上还未同步的仓库(GitHub 上不存在同名仓库时会按设置自动创建):

当前同步项目(0)

项目状态最近推送同步操作
移除只是停止同步,GitHub 上的仓库和本地镜像数据都会保留,以后可以随时加回来。
""" PAGE_SETTINGS = """

GitHub 账号推送目标 · 可添加多个,随时切换

备注GitHub 用户名Token推送方式自动建库操作
切换账号后,所有项目改推到该账号名下;每个账号独立记录同步进度,切回原账号不会重复推送。

添加 / 更新 GitHub 账号Token 验证通过后自动识别用户名

添加多个账号后可随时切换推送目标,每个账号独立记录同步进度。已存在的账号可点表格行右边的 ✏️ 编辑。

Token 获取:GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token,勾选 repo 权限(即可自动创建仓库并推送)。
链接:github.com/settings/tokens · Token 只保存在 NAS 本地(accounts.json,权限 600),不会上传到任何地方。

Gitea 与同步设置

GitHub 上不存在同名仓库时自动创建
需要当前账号已配置 Token;关闭后添加不存在的仓库会提示手动创建
每轮都核对 GitHub 实况(严格模式)
推送前先读 GitHub 上的分支和提交号,一致就跳过(推送前后都会核对)。关闭时只在本地有新提交时才访问 GitHub,更省流量;开启后即使你手动改过 GitHub 也能立刻发现并纠正
同步间隔(秒)
同时同步项目数
失败重试次数
""" # ---------------- HTTP 服务 ---------------- class Handler(BaseHTTPRequestHandler): def log_message(self, *a): pass def _json(self, obj, code=200): body = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _html(self, html): body = html.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _body(self): try: n = int(self.headers.get("Content-Length", 0) or 0) return json.loads(self.rfile.read(n) or b"{}") except Exception: return {} def do_GET(self): path = self.path.split("?")[0].rstrip("/") or "/" if path == "/": acc = active_account() body = (PAGE_HOME.replace("__GITEA__", gitea_ui( self.headers.get("Host"), self.headers.get("Referer"), self.headers.get("X-Forwarded-Host"))) .replace("__GH__", f"https://github.com/{acc['gh_user']}?tab=repositories" if acc else "") .replace("__LOGPATH__", str(LOG_FILE))) self._html(page("控制面板", body, "home")) elif path == "/status": self._html(page("项目状态", PAGE_STATUS, "status")) elif path == "/manage": self._html(page("项目管理", PAGE_MANAGE, "manage")) elif path == "/settings": self._html(page("设置", PAGE_SETTINGS, "settings")) elif path == "/api/state": repos = load_repos() acc = active_account() self._json({"enabled": state["enabled"], "syncing": state["syncing"], "last_check": state["last_check"], "checks": state["checks"], "last_result": state["last_result"], "interval": sync_interval(), "max_workers": sync_workers(), "repo_count": len(repos), "synced_count": sum(1 for r in repos if state_path(acc, r).exists()), "uptime": fmt_uptime(time.time() - START_TS), "account": account_public(acc), "last_real": last_real_sync()}) elif path == "/api/status": self._json(global_status(gitea_ui( self.headers.get("Host"), self.headers.get("Referer"), self.headers.get("X-Forwarded-Host")))) elif path == "/api/settings": self._json({"gitea_user": cfg["gitea_user"], "gitea_pass": mask_token(cfg.get("gitea_pass")), "gitea_pass_set": bool(cfg.get("gitea_pass")), "auto_create": bool(cfg.get("auto_create", True)), "interval": sync_interval(), "min_interval": MIN_INTERVAL, "max_interval": MAX_INTERVAL, "max_workers": sync_workers(), "min_workers": MIN_WORKERS, "max_workers_limit": MAX_WORKERS, "max_retries": max_retries(), "retry_interval": RETRY_INTERVAL, "verify_github": verify_strict(), "gitea_host": gitea_host(), "open_url": cfg.get("open_url", ""), "gitea_url": gitea_ui( self.headers.get("Host"), self.headers.get("Referer"), self.headers.get("X-Forwarded-Host")), "active": active_id, "accounts": [account_public(a) for a in accounts]}) elif path == "/api/log": self._json({"lines": tail_log(80)}) else: self._json({"error": "not found"}, 404) def do_POST(self): global active_id path = self.path.split("?")[0].rstrip("/") or "/" data = self._body() if path == "/api/toggle": state["enabled"] = not state["enabled"] save_panel_state() self._json({"ok": True, "enabled": state["enabled"]}) elif path == "/api/sync": ok, msg = run_sync() self._json({"ok": ok, "msg": msg}) elif path == "/api/settings/save": new_user = new_pass = new_host = None if "gitea_user" in data: u = (data.get("gitea_user") or "").strip() if not valid_name(u): self._json({"ok": False, "msg": "Gitea 用户名不合法(仅限字母/数字/._-)"}) return new_user = u if "gitea_pass" in data: new_pass = str(data.get("gitea_pass") or "") # 空串 = 清除密码 if "gitea_host" in data: h = norm_gitea_host(data.get("gitea_host")) if not h: self._json({"ok": False, "msg": "Gitea 地址不合法,应类似 http://127.0.0.1:3000"}) return new_host = h if "open_url" in data: v = (data.get("open_url") or "").strip() if v: if "://" not in v: self._json({"ok": False, "msg": "「打开 Gitea 链接」必须带协议头,如 http://nas.local:3000 或 https://..."}) return if not norm_gitea_host(v): self._json({"ok": False, "msg": "「打开 Gitea 链接」格式不合法,应类似 http://nas.local:3000(留空则用默认推断)"}) return if "interval" in data: if not valid_interval(data.get("interval")): self._json({"ok": False, "msg": f"同步间隔须为 {MIN_INTERVAL} ~ {MAX_INTERVAL} 之间的整数(秒)"}) return if "max_workers" in data: if not valid_workers(data.get("max_workers")): self._json({"ok": False, "msg": f"同时同步项目数须为 {MIN_WORKERS} ~ {MAX_WORKERS} 之间的整数"}) return if "max_retries" in data: if not valid_retries(data.get("max_retries")): self._json({"ok": False, "msg": f"失败重试次数须为 {MIN_MAX_RETRIES} ~ {MAX_MAX_RETRIES} 之间的整数"}) return # 预校验:新地址先探连通性;涉及账号的改动再用新凭据调一次 /user 验证 trial = dict(cfg) if new_user is not None: trial["gitea_user"] = new_user if new_pass is not None: trial["gitea_pass"] = new_pass if new_host is not None: trial["gitea_host"] = new_host ok, msg = gitea_probe(new_host) if not ok: self._json({"ok": False, "msg": msg}) return if (new_user is not None or new_pass is not None) and trial["gitea_pass"]: try: r = subprocess.run( ["curl", "-sS", "-m", "10", "-w", "\n%{http_code}", "-u", f"{trial['gitea_user']}:{trial['gitea_pass']}", f"{trial['gitea_host']}/api/v1/user"], capture_output=True, text=True, timeout=15) out, _, code = r.stdout.rpartition("\n") st = int(code) if code.strip().isdigit() else 0 if not (200 <= st < 300): self._json({"ok": False, "msg": f"Gitea 账号验证失败(HTTP {st}):请检查用户名/密码"}) return login = (json.loads(out or "{}") or {}).get("login") if login and login != trial["gitea_user"]: self._json({"ok": False, "msg": f"该密码对应的 Gitea 用户是 {login},与填写的 {trial['gitea_user']} 不一致"}) return except Exception as e: self._json({"ok": False, "msg": f"Gitea 账号验证出错: {e}"}) return host_changed = new_host is not None and new_host != gitea_host() if new_user is not None: cfg["gitea_user"] = new_user if new_pass is not None: cfg["gitea_pass"] = new_pass log("Gitea 密码已" + ("更新" if new_pass else "清除")) if new_host is not None: cfg["gitea_host"] = new_host if "open_url" in data: cfg["open_url"] = (data.get("open_url") or "").strip() if "auto_create" in data: cfg["auto_create"] = bool(data.get("auto_create")) if "interval" in data: old_iv = sync_interval() cfg["interval"] = int(data.get("interval")) if cfg["interval"] != old_iv: log(f"同步间隔已调整: {old_iv}s -> {cfg['interval']}s") if "max_workers" in data: old_w = sync_workers() cfg["max_workers"] = int(data.get("max_workers")) if cfg["max_workers"] != old_w: log(f"同时同步项目数已调整: {old_w} -> {cfg['max_workers']}") if "max_retries" in data: old_r = max_retries() cfg["max_retries"] = int(data.get("max_retries")) if cfg["max_retries"] != old_r: log(f"失败重试次数已调整: {old_r} -> {cfg['max_retries']}") if "verify_github" in data: old_v = verify_strict() cfg["verify_github"] = bool(data.get("verify_github")) if cfg["verify_github"] != old_v: log("GitHub 实况核对已" + ("开启(每轮都对比分支/提交号)" if cfg["verify_github"] else "关闭(仅本地有变化时对比)")) save_settings() notes = [] if new_host is not None: ok, msg = gitea_probe() notes.append(f"Gitea 地址已设为 {new_host}({msg})") if host_changed: n = repoint_mirror_remotes() log(f"Gitea 地址变更: {n} 个镜像仓库的 origin 已更新") notes.append(f"{n} 个已有镜像的拉取地址已更新") if cfg.get("gitea_pass"): notes.append("Gitea 密码验证通过 ✓,可同步私有仓库") else: ok, d = gitea_api(f"/users/{cfg['gitea_user']}") if ok and isinstance(d, dict) and d.get("login"): notes.append("未配置 Gitea 密码,只能同步公开仓库;仓库为私有时请填写密码") else: notes.append(f"注意:在新地址上找不到用户 {cfg['gitea_user']},请检查") self._json({"ok": True, "msg": "已保存(" + ";".join(notes) + ")"}) elif path == "/api/accounts/add": # 严格新增:Token 对应的 gh_user 若已存在则拒绝,引导用户用编辑功能 token = (data.get("token") or "").strip() label = (data.get("label") or "").strip() gh_user = (data.get("gh_user") or "").strip() private = bool(data.get("private", True)) if not token: self._json({"ok": False, "msg": "Token 不能为空"}) return ok, login, err = gh_verify_token(token) if not ok: self._json({"ok": False, "msg": f"Token 验证失败: {err}"}) return if gh_user and gh_user != login: self._json({"ok": False, "msg": f"Token 对应的账号是 {login},与填写的 {gh_user} 不一致"}) return if next((a for a in accounts if a["gh_user"] == login), None): self._json({"ok": False, "msg": f"GitHub 账号 {login} 已存在,请用编辑功能(每行右边的✏️)更新 Token/备注"}) return acc = {"id": uuid.uuid4().hex[:8], "label": label or login, "gh_user": login, "token": token, "private": private, "created": datetime.now().strftime("%Y-%m-%d %H:%M:%S")} accounts.append(acc) # 旧版默认账号(无 Token)存在时,添加第一个 Token 账号后自动切过去 cur = active_account() if (not cur) or (not cur.get("token")): active_id = acc["id"] save_accounts() log(f"新增 GitHub 账号 {login}({'私有' if private else '公开'}仓库)") self._json({"ok": True, "msg": f"账号 {login} 验证通过,已添加" + ("并切换为当前账号" if active_id == acc["id"] else "")}) elif path == "/api/accounts/edit": # 按 id 编辑已有账号:token 留空则保留,可改 label / private aid = (data.get("id") or "").strip() acc = find_account(aid) if not acc: self._json({"ok": False, "msg": "账号不存在"}) return new_token = (data.get("token") or "").strip() if new_token: ok, login, err = gh_verify_token(new_token) if not ok: self._json({"ok": False, "msg": f"Token 验证失败: {err}"}) return if login != acc["gh_user"]: self._json({"ok": False, "msg": f"Token 对应的账号是 {login},与当前账号 {acc['gh_user']} 不一致"}) return acc["token"] = new_token log(f"GitHub 账号 {acc['gh_user']} 的 Token 已通过编辑更新") if "private" in data: acc["private"] = bool(data.get("private")) if "label" in data: label = (data.get("label") or "").strip() if label: acc["label"] = label save_accounts() self._json({"ok": True, "msg": f"账号 {acc['gh_user']} 已保存" + ("(Token 已更新)" if new_token else "")}) elif path == "/api/accounts/switch": aid = (data.get("id") or "").strip() if not find_account(aid): self._json({"ok": False, "msg": "账号不存在"}) return active_id = aid save_accounts() acc = find_account(aid) log(f"已切换 GitHub 账号: {acc['label']}({acc['gh_user']})") self._json({"ok": True, "msg": f"已切换到 {acc['label']}({acc['gh_user']})"}) elif path == "/api/accounts/remove": aid = (data.get("id") or "").strip() acc = find_account(aid) if not acc: self._json({"ok": False, "msg": "账号不存在"}) return if len(accounts) <= 1: self._json({"ok": False, "msg": "至少保留一个账号"}) return accounts.remove(acc) if active_id == aid: active_id = accounts[0]["id"] save_accounts() log(f"已移除 GitHub 账号 {acc['gh_user']}") self._json({"ok": True, "msg": f"账号 {acc['gh_user']} 已移除(GitHub 仓库不受影响)"}) elif path == "/api/accounts/verify": aid = (data.get("id") or "").strip() acc = find_account(aid) if not acc: self._json({"ok": False, "msg": "账号不存在"}) return if not acc.get("token"): self._json({"ok": True, "msg": f"{acc['gh_user']}:SSH 私钥推送(无需 Token)"}) return ok, login, err = gh_verify_token(acc["token"]) if ok: self._json({"ok": True, "msg": f"Token 有效 ✓({login})"}) else: self._json({"ok": False, "msg": f"Token 校验失败: {err}"}) elif path == "/api/repos/check": # 只核对、不推送:看 GitHub 上的分支/提交号是否与本地一致 name = (data.get("name") or "").strip() if not valid_name(name): self._json({"ok": False, "msg": "非法仓库名"}) return acc = active_account() if not acc: self._json({"ok": False, "msg": "没有可用的 GitHub 账号"}) return repo = REPO_DIR / f"{name}.git" if not repo.exists(): self._json({"ok": False, "msg": f"{name} 还没克隆到本地,请先同步一次"}) return verdict, detail = compare_remote(acc, name, repo) note_compare(name, verdict, detail) if verdict == "same": self._json({"ok": True, "same": True, "msg": f"{name}:GitHub 与本地一致({detail}),无需推送"}) elif verdict == "diff": self._json({"ok": True, "same": False, "msg": f"{name}:GitHub 与本地不同 → {detail};点「同步」即可推送"}) else: self._json({"ok": False, "msg": f"{name}:{detail}"}) elif path == "/api/repos/checkall": acc = active_account() if not acc: self._json({"ok": False, "msg": "没有可用的 GitHub 账号"}) return repos = load_repos() same, diff, unknown = [], [], [] def worker(n): repo = REPO_DIR / f"{n}.git" if not repo.exists(): return ("unknown", "尚未克隆") v, d = compare_remote(acc, n, repo) note_compare(n, v, d) return (v, d) with ThreadPoolExecutor(max_workers=max(1, min(sync_workers(), len(repos) or 1)), thread_name_prefix="ghs-check") as ex: res = dict(zip(repos, ex.map(worker, repos))) for n in repos: v = (res.get(n) or ("unknown", ""))[0] (same if v == "same" else diff if v == "diff" else unknown).append(n) log(f"核对 GitHub 实况:一致 {len(same)} 个,不同 {len(diff)} 个,无法核对 {len(unknown)} 个") self._json({"ok": True, "same": same, "diff": diff, "unknown": unknown, "msg": f"核对完成:{len(same)} 个一致(会跳过推送)," f"{len(diff)} 个需要推送,{len(unknown)} 个无法核对"}) elif path == "/api/repos/sync": name = data.get("name", "") if not valid_name(name): self._json({"ok": False, "msg": "非法仓库名"}) return # 不再被整轮同步全局拦截:只禁止同一仓库重复同步(sync_repo 内部按仓库防重入), # 与正在进行的整轮同步互不阻塞,实现多项目同时同步 res = sync_repo(name) state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if res == "busy": self._json({"ok": False, "msg": f"{name} 正在同步中,请稍候"}) elif res.startswith("error"): bump_stats(total_failures=1) self._json({"ok": False, "msg": f"{name} {res}"}) elif res == "changed": bump_stats(total_pushes=1) clear_retry(name) # 手动同步成功,退出自动重试队列 self._json({"ok": True, "msg": f"{name} 已同步到 GitHub"}) else: clear_retry(name) self._json({"ok": True, "msg": f"{name} 已是最新,无需同步"}) elif path == "/api/repos/add": name = (data.get("name") or "").strip() if not valid_name(name): self._json({"ok": False, "msg": "仓库名不合法(仅限字母/数字/._-)"}) return repos = load_repos() if name in repos: self._json({"ok": False, "msg": f"{name} 已在同步列表中"}) return if not gitea_repo_exists(name): self._json({"ok": False, "msg": f"Gitea 上不存在仓库 {cfg['gitea_user']}/{name},请检查名字"}) return acc = active_account() # GitHub 上不存在同名仓库时,按设置自动创建(需 Token) if not github_repo_exists(acc, name): if not (acc.get("token") and cfg.get("auto_create", True)): self._json({"ok": False, "msg": f"GitHub 上不存在 {acc['gh_user']}/{name},且当前账号无法自动创建(需在设置页添加带 Token 的账号并开启自动创建)"}) return ok, msg = github_create_repo(acc, name) if not ok: self._json({"ok": False, "msg": f"自动创建 GitHub 仓库失败: {msg}"}) return log(f"{name}: GitHub 仓库不存在,添加时已自动创建({'私有' if acc.get('private', True) else '公开'})") repos.append(name) save_repos(repos) log(f"{name}: 已加入同步列表") res = sync_repo(name) if res == "busy": self._json({"ok": True, "msg": f"{name} 添加成功(该仓库正在同步中,稍后刷新看结果)"}) elif res.startswith("error"): self._json({"ok": False, "msg": f"已加入列表,但首次同步失败:{res}"}) elif res == "changed": bump_stats(total_pushes=1) clear_retry(name) self._json({"ok": True, "msg": f"{name} 添加成功,已完成首次同步"}) else: clear_retry(name) self._json({"ok": True, "msg": f"{name} 添加成功,内容已是最新"}) elif path == "/api/repos/remove": name = (data.get("name") or "").strip() repos = load_repos() if name not in repos: self._json({"ok": False, "msg": f"{name} 不在同步列表中"}) return repos = [r for r in repos if r != name] save_repos(repos) clear_retry(name) log(f"{name}: 已从同步列表移除") self._json({"ok": True, "msg": f"{name} 已移除(GitHub 仓库与本地数据保留)"}) else: self._json({"error": "not found"}, 404) def last_real_sync(): try: for line in reversed(LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()): if "已同步到 GitHub" in line: return line.strip() except Exception: pass return "尚无记录" def write_pidfile(): try: DATA_DIR.mkdir(parents=True, exist_ok=True) PID_FILE.write_text(str(os.getpid())) except Exception: pass def main(): signal.signal(signal.SIGHUP, signal.SIG_IGN) DATA_DIR.mkdir(parents=True, exist_ok=True) (DATA_DIR / "logs").mkdir(exist_ok=True) # fnOS 解包可能把密钥权限放宽,ssh 会拒绝;守护进程即属主,启动时自愈 try: if SSH_KEY.exists(): os.chmod(SSH_KEY, 0o600) except Exception: pass write_pidfile() atexit.register(lambda: PID_FILE.exists() and PID_FILE.unlink()) load_settings() load_accounts() load_panel_state() load_stats() load_repos() acc = active_account() log(f"GiteaHubSync 守护进程启动 (pid {os.getpid()}, 自动同步={'开' if state['enabled'] else '关'}, " f"项目 {len(load_repos())} 个, GitHub 账号={acc['label'] if acc else '无'}" f"({acc['gh_user'] if acc else '-'}/{'Token' if acc and acc.get('token') else 'SSH'}), " f"Gitea 用户={cfg['gitea_user']}, 地址={gitea_host()}, 自动创建={'开' if cfg.get('auto_create') else '关'})") threading.Thread(target=loop, daemon=True).start() threading.Thread(target=retry_loop, daemon=True).start() server = ThreadingHTTPServer(("0.0.0.0", PANEL_PORT), Handler) log(f"控制面板监听 :{PANEL_PORT}") server.serve_forever() if __name__ == "__main__": main()