From 74f7270cbcd0ebc5c4308f1ad52562a849c57ae8 Mon Sep 17 00:00:00 2001 From: fn-deepseek-harness Date: Sat, 17 Oct 2026 20:26:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v1.6.0=20=E2=80=94=20=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E5=89=8D=E5=85=88=E6=A0=B8=E5=AF=B9=20GitHub=20=E5=AE=9E?= =?UTF-8?q?=E5=86=B5=EF=BC=88=E5=88=86=E6=94=AF/=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=8F=B7=E4=B8=80=E8=87=B4=E5=B0=B1=E8=B7=B3=E8=BF=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 remote_refs():git ls-remote 读取 GitHub 真实的分支/标签/提交号 - 新增 compare_remote():与本地镜像逐条比对,返回 same/diff/unknown 并给出差异说明 - 同步流程改为:本地无变化 → 默认不打扰 GitHub;本地有变化或严格模式 → 先比对, 一致则跳过推送并重建同步记录,不同才推送;推送后再核对一次确认落地 - 新增设置项 verify_github(默认关):严格模式每轮都核对 GitHub 实况 - 新增 API:/api/repos/check(单仓库核对)、/api/repos/checkall(全量核对,不推送) - 状态页新增「GitHub 核对」列 + 单仓库「核对」按钮 + 「🔍 核对 GitHub 实况」 - stats 新增 total_skipped 统计(一致跳过推送次数),状态页新增对应卡片 - 解决:GitHub 被手动改动后永不纠正;同步记录丢失后整仓重推一遍 --- GiteaHubSync/app/backend/panel.py | 229 ++++++++++++++++++++++++++++-- GiteaHubSync/manifest | 2 +- README.md | 12 +- 3 files changed, 229 insertions(+), 14 deletions(-) diff --git a/GiteaHubSync/app/backend/panel.py b/GiteaHubSync/app/backend/panel.py index 5d50410..68b60db 100644 --- a/GiteaHubSync/app/backend/panel.py +++ b/GiteaHubSync/app/backend/panel.py @@ -72,6 +72,7 @@ ENV["GIT_TERMINAL_PROMPT"] = "0" 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 + 自动推断) @@ -173,6 +174,11 @@ def valid_retries(v): 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")) @@ -186,6 +192,7 @@ def load_settings(): 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 @@ -303,12 +310,15 @@ def scrub(s, acc): 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} +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 比对结果 # ---------------- 基础工具 ---------------- @@ -542,6 +552,82 @@ def list_gitea_repos(): # ---------------- 同步逻辑 ---------------- +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: @@ -585,9 +671,12 @@ def _sync_repo_locked(name, acc): return "error: 读取 refs 失败" st = state_path(acc, name) prev = st.read_text().strip() if st.exists() else "" - if cur == prev: + locally_changed = (cur != prev) + # 本地没变化时默认不打扰 GitHub(快);严格模式下每轮都核对 GitHub 实况 + if not locally_changed and not verify_strict(): + note_compare(name, "same", "本地无新提交,未访问 GitHub") return "same" - # 推送前确保 GitHub 仓库存在;不存在时按设置自动创建 + # 推送前先看 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) @@ -600,15 +689,32 @@ def _sync_repo_locked(name, acc): 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) - detail = " ".join(l.strip() for l in p.stdout.splitlines() - if any(k in l for k in ("new branch", "new tag", "deleted", "forced", "->"))) + 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(detail, acc)}" if detail else "")) + + (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)}" @@ -794,7 +900,10 @@ def repo_detail(name): 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} + "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") @@ -860,6 +969,7 @@ def global_status(ui_url=None): "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"], @@ -1165,6 +1275,7 @@ PAGE_STATUS = """
距下次检查
—
累计检查轮数
—
累计实际同步
—
+
一致跳过推送
—
累计失败次数
—
失败重试
—
镜像总占用
—
@@ -1175,9 +1286,13 @@ PAGE_STATUS = """

项目明细

+
+
+ +
- +
项目状态默认分支分支/标签最近推送同步最新提交大小
项目状态默认分支分支/标签最近推送同步GitHub 核对最新提交大小
@@ -1196,6 +1311,7 @@ async function refresh(){ document.getElementById('sNext').textContent=s.enabled?(s.next_in==null?'—':s.next_in+' 秒'):'已暂停'; document.getElementById('sChecks').textContent=s.stats.total_checks; document.getElementById('sPushes').textContent=s.stats.total_pushes; + document.getElementById('sSkipped').textContent=(s.stats.total_skipped||0); document.getElementById('sFails').textContent=s.stats.total_failures; const rt=s.retry||{}; document.getElementById('sRetry').textContent=(rt.pending&&rt.pending.length) @@ -1203,15 +1319,38 @@ async function refresh(){ :'无待重试'; document.getElementById('sSize').textContent=s.total_size_mb+' MB'; document.getElementById('sTarget').textContent='同步目标: '+(s.account?s.account.gh_user+'('+s.account.method+')':'未配置')+' · '+(s.max_workers||1)+' 个并行 · Gitea '+(s.gitea_online?'在线 ✓':'离线 ✗'); + const vg=!!s.verify_github; + document.getElementById('cmpTip').innerHTML='推送前会先读 GitHub 上的分支和提交号:一致就跳过,不同才推送。' + +'当前模式:'+(vg?'每轮都核对(严格)':'仅本地有新提交时核对(省流量)')+',可在设置里切换。'; document.getElementById('tb').innerHTML=s.repos.map(r=>{ const att=(rt.attempts||{})[r.name]; const stTxt=r.status==='已同步'?'✓ 已同步':(att!=null?('⏳ 重试中 '+att+'/'+(rt.max||5)):'⏳ 未同步'); const stCls=r.status==='已同步'?'ok':'warn'; const st=`${stTxt}`; + const cp=r.compare; + let cpHtml='未核对'; + if(cp){ + const map={same:['✓ 一致','ok'],diff:['≠ 不同','warn'],unknown:['? 未知','warn']}; + const mm=map[cp.verdict]||['? 未知','warn']; + cpHtml=`${mm[0]}` + +`
${esc(cp.at)}
`; + } const c=r.last_commit?`
${esc(r.last_commit.hash)} ${esc(r.last_commit.msg)} · ${esc(r.last_commit.author)} · ${esc(r.last_commit.time)}
`:'
—
'; - return `${esc(r.name)}${st}${esc(r.head||'—')}${r.branches} / ${r.tags}${r.last_push||'—'}${c}${r.size_mb} MB`; - }).join('')||'还没有同步项目,去项目管理添加'; - }catch(e){document.getElementById('tb').innerHTML='刷新失败: '+e+''} + return `${esc(r.name)}${st}${esc(r.head||'—')}${r.branches} / ${r.tags}${r.last_push||'—'}${cpHtml}${c}${r.size_mb} MB `; + }).join('')||'还没有同步项目,去项目管理添加'; + }catch(e){document.getElementById('tb').innerHTML='刷新失败: '+e+''} +} +async function checkOne(n){ + const r=await (await fetch('api/repos/check',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})})).json(); + alert(r.msg||JSON.stringify(r));refresh(); +} +async function checkAll(){ + const b=document.getElementById('btnCheck');b.disabled=true;b.textContent='⏳ 核对中…'; + try{ + const r=await (await fetch('api/repos/checkall',{method:'POST'})).json(); + alert(r.msg||JSON.stringify(r)); + }catch(e){alert('核对失败: '+e)} + finally{b.disabled=false;b.textContent='🔍 核对 GitHub 实况';refresh();} } async function syncOne(n){ const r=await (await fetch('api/repos/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})})).json(); @@ -1333,6 +1472,13 @@ PAGE_SETTINGS = """
需要当前账号已配置 Token;关闭后添加不存在的仓库会提示手动创建
+
+ +
+
每轮都核对 GitHub 实况(严格模式)
+
推送前先读 GitHub 上的分支和提交号,一致就跳过(推送前后都会核对)。关闭时只在本地有新提交时才访问 GitHub,更省流量;开启后即使你手动改过 GitHub 也能立刻发现并纠正
+
+
@@ -1404,6 +1550,7 @@ async function refresh(){ document.getElementById('giteaUrlTip').textContent='「打开 Gitea」按钮地址:'+(s.gitea_url||'—')+'(随访问地址自动适配);密码只保存在 NAS 本地,仅用于读取私有仓库。'; document.getElementById('openUrl').value=s.open_url||''; document.getElementById('swAuto').checked=!!s.auto_create; + document.getElementById('swVerify').checked=!!s.verify_github; const iv=document.getElementById('syncInterval'); iv.value=s.interval||10;iv.min=s.min_interval||5;iv.max=s.max_interval||86400; document.getElementById('intervalTip').textContent='每隔 '+fmtInterval(s.interval||10)+' 检查一次 Gitea 是否有新提交('+(s.min_interval||5)+'~'+(s.max_interval||86400)+' 秒),改动即生效'; @@ -1554,6 +1701,11 @@ async function saveAuto(){ const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({auto_create})})).json(); showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok);refresh(); } +async function saveVerify(){ + const verify_github=document.getElementById('swVerify').checked; + const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({verify_github})})).json(); + showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok);refresh(); +} function fmtInterval(s){ s=parseInt(s,10)||0; if(s>=3600)return (s/3600)+' 小时'; @@ -1735,6 +1887,7 @@ class Handler(BaseHTTPRequestHandler): "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( @@ -1854,6 +2007,12 @@ class Handler(BaseHTTPRequestHandler): 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: @@ -1971,6 +2130,54 @@ class Handler(BaseHTTPRequestHandler): 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): diff --git a/GiteaHubSync/manifest b/GiteaHubSync/manifest index 2a2775b..1a77f90 100644 --- a/GiteaHubSync/manifest +++ b/GiteaHubSync/manifest @@ -1,5 +1,5 @@ appname = GiteaHubSync -version = 1.5.6 +version = 1.6.0 display_name = Gitea→GitHub 同步助手 desc = 每次推送到飞牛 Gitea 的代码,10 秒内自动镜像到 GitHub 的 7 个私有仓库。内置控制面板:自动同步开关、立即同步、仓库状态、同步日志,一键打开 Gitea。数据全部保存在 NAS 本地。 platform = all diff --git a/README.md b/README.md index 0ac47a0..1e03996 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,13 @@ NAS 重启自动恢复。控制面板带自动同步开关、立即同步、仓 - **失败自动重试**:同步失败后每隔 1 分钟自动重试失败的仓库,最多 N 次 (默认 5,可调 0~20,0 = 不重试);重试与同步间隔无关,专治网络抖动, 重试中的仓库在状态页显示「重试中 x/N」 +- **推送前先核对 GitHub 实况**:每轮同步先读 GitHub 上该仓库**真实的分支和提交号** + (`git ls-remote`),与本地镜像逐条比对——**完全一致就跳过推送**,不同才推; + 推送成功后再核对一次,确认 GitHub 真的和本地一致。好处:① 不做无意义推送、省流量; + ② 你在 GitHub 上手动删了分支或回退了提交,下一轮就能发现并纠正; + ③ 同步记录文件丢了也不会整仓重推一遍。默认「仅本地有新提交时核对」(省流量), + 设置页可开 **严格模式**每轮都核对;状态页有「GitHub 核对」列(✓ 一致 / ≠ 不同 / ? 未知)、 + 单仓库「核对」按钮和「🔍 核对 GitHub 实况」一键全量核对(**只核对不推送**) - **Gitea 地址可改**:设置页可改同步引擎访问的 Gitea 地址(默认 `http://127.0.0.1:3000`), 保存前自动探测连通性;改地址后已有镜像的拉取地址自动更新,「打开 Gitea」按钮随访问地址自适应 - **自动创建仓库**:Gitea 上的新仓库加入同步时,若 GitHub 上不存在同名仓库, @@ -88,5 +95,6 @@ cd /vol1/@appshare/fn-deepseek-harness/fnos-gitea-sync 端口直连,私钥随应用打包安装在 NAS 本地 - 从 v1.0.0 升级会自动迁移:旧的同步进度归入「默认(SSH 私钥)」账号,行为不变 - 数据文件:`accounts.json`(GitHub 账号,含 Token)、`settings.json` - (Gitea 地址/用户名/密码、自动创建开关、同步间隔)、`repos.json`(同步列表)、 - `.last-pushed/<账号>/<仓库>`(每个账号的同步进度) + (Gitea 地址/用户名/密码、自动创建开关、同步间隔、GitHub 严格核对开关)、 + `repos.json`(同步列表)、`.last-pushed/<账号>/<仓库>`(每个账号的同步进度, + 仅作「本地有没有新提交」的快速判断;真正的跳过推送以 GitHub 实况比对为准)