feat: v1.6.0 — 推送前先核对 GitHub 实况(分支/提交号一致就跳过)

- 新增 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 被手动改动后永不纠正;同步记录丢失后整仓重推一遍
This commit is contained in:
fn-deepseek-harness
2026-10-17 20:26:19 +08:00
parent 1b2ddded38
commit 74f7270cbc
3 changed files with 229 additions and 14 deletions
+218 -11
View File
@@ -72,6 +72,7 @@ ENV["GIT_TERMINAL_PROMPT"] = "0"
cfg = {"gitea_user": LEGACY_GITEA_USER, "gitea_pass": "", "auto_create": True, cfg = {"gitea_user": LEGACY_GITEA_USER, "gitea_pass": "", "auto_create": True,
"interval": DEFAULT_INTERVAL, "gitea_host": DEFAULT_GITEA_HOST, "interval": DEFAULT_INTERVAL, "gitea_host": DEFAULT_GITEA_HOST,
"max_workers": DEFAULT_WORKERS, "max_retries": DEFAULT_MAX_RETRIES, "max_workers": DEFAULT_WORKERS, "max_retries": DEFAULT_MAX_RETRIES,
"verify_github": False, # True=每轮都向 GitHub 核对分支/提交号(严格校验)
"open_url": ""} # 「打开 Gitea」按钮专用链接(留空则用 gitea_host + 自动推断) "open_url": ""} # 「打开 Gitea」按钮专用链接(留空则用 gitea_host + 自动推断)
@@ -173,6 +174,11 @@ def valid_retries(v):
return False return False
def verify_strict():
"""是否每轮都向 GitHub 核对实况(False=仅本地有变化时才核对,省流量)"""
return bool(cfg.get("verify_github", False))
def load_settings(): def load_settings():
try: try:
d = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) d = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
@@ -186,6 +192,7 @@ def load_settings():
cfg["max_workers"] = int(d["max_workers"]) cfg["max_workers"] = int(d["max_workers"])
if valid_retries(d.get("max_retries")): if valid_retries(d.get("max_retries")):
cfg["max_retries"] = int(d["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")) h = norm_gitea_host(d.get("gitea_host"))
if h: if h:
cfg["gitea_host"] = h cfg["gitea_host"] = h
@@ -303,12 +310,15 @@ def scrub(s, acc):
state = {"enabled": True, "syncing": False, "last_check": None, "checks": 0, state = {"enabled": True, "syncing": False, "last_check": None, "checks": 0,
"last_result": "尚未运行", "loop_last": None} "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() # 整轮同步互斥(同一轮只跑一次) op_lock = threading.Lock() # 整轮同步互斥(同一轮只跑一次)
stats_lock = threading.Lock() # 保护 stats 计数 stats_lock = threading.Lock() # 保护 stats 计数
log_lock = threading.Lock() # 保护日志文件写入 log_lock = threading.Lock() # 保护日志文件写入
repo_busy_lock = threading.Lock() # 保护 repo_in_progress repo_busy_lock = threading.Lock() # 保护 repo_in_progress
repo_in_progress = set() # 正在同步的仓库名(按仓库防重入) 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):
"""把 '<sha>\t<ref>' 文本行解析成 {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): def fingerprint(repo):
r = git(["-C", str(repo), "for-each-ref", "--format=%(refname) %(objectname)"]) r = git(["-C", str(repo), "for-each-ref", "--format=%(refname) %(objectname)"])
if r.returncode != 0: if r.returncode != 0:
@@ -585,9 +671,12 @@ def _sync_repo_locked(name, acc):
return "error: 读取 refs 失败" return "error: 读取 refs 失败"
st = state_path(acc, name) st = state_path(acc, name)
prev = st.read_text().strip() if st.exists() else "" 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" return "same"
# 推送前确保 GitHub 仓库存在;不存在时按设置自动创建 # 推送前先看 GitHub 上真实的分支/提交号,一致就不推(省一次大流量推送)
if not github_repo_exists(acc, name): if not github_repo_exists(acc, name):
if acc.get("token") and cfg.get("auto_create", True): if acc.get("token") and cfg.get("auto_create", True):
ok, msg = github_create_repo(acc, name) ok, msg = github_create_repo(acc, name)
@@ -600,15 +689,32 @@ def _sync_repo_locked(name, acc):
if not acc.get("token") else if not acc.get("token") else
"自动创建已关闭,请到设置页开启或在 GitHub 手动创建") "自动创建已关闭,请到设置页开启或在 GitHub 手动创建")
return f"error: GitHub 上不存在 {acc['gh_user']}/{name},{hint}" 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)]) p = git(["-C", str(repo), "push", "--mirror", gh_push_url(acc, name)])
if p.returncode != 0: if p.returncode != 0:
return "error: 推送失败 " + scrub((p.stderr or p.stdout or "")[:120], acc) return "error: 推送失败 " + scrub((p.stderr or p.stdout or "")[:120], acc)
st.parent.mkdir(parents=True, exist_ok=True) st.parent.mkdir(parents=True, exist_ok=True)
st.write_text(cur) st.write_text(cur)
detail = " ".join(l.strip() for l in p.stdout.splitlines() detail2 = " ".join(l.strip() for l in p.stdout.splitlines()
if any(k in l for k in ("new branch", "new tag", "deleted", "forced", "->"))) 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']})" 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" return "changed"
except Exception as e: except Exception as e:
return f"error: {scrub(str(e), acc)}" return f"error: {scrub(str(e), acc)}"
@@ -794,7 +900,10 @@ def repo_detail(name):
st = state_path(active_account(), name) st = state_path(active_account(), name)
d = {"name": name, "cloned": repo.exists(), "branches": 0, "tags": 0, d = {"name": name, "cloned": repo.exists(), "branches": 0, "tags": 0,
"head": None, "last_commit": None, "size_mb": 0.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(): if st.exists():
d["status"] = "已同步" d["status"] = "已同步"
d["last_push"] = datetime.fromtimestamp(st.stat().st_mtime).strftime("%m-%d %H:%M:%S") 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(), "interval": sync_interval(),
"max_workers": sync_workers(), "max_workers": sync_workers(),
"retry": retry_snapshot(), "retry": retry_snapshot(),
"verify_github": verify_strict(),
"last_check": state["last_check"], "last_check": state["last_check"],
"next_in": next_in, "next_in": next_in,
"checks": state["checks"], "checks": state["checks"],
@@ -1165,6 +1275,7 @@ PAGE_STATUS = """
<div class="stat"><div class="k">距下次检查</div><div class="v" id="sNext">—</div></div> <div class="stat"><div class="k">距下次检查</div><div class="v" id="sNext">—</div></div>
<div class="stat"><div class="k">累计检查轮数</div><div class="v" id="sChecks">—</div></div> <div class="stat"><div class="k">累计检查轮数</div><div class="v" id="sChecks">—</div></div>
<div class="stat"><div class="k">累计实际同步</div><div class="v" id="sPushes">—</div></div> <div class="stat"><div class="k">累计实际同步</div><div class="v" id="sPushes">—</div></div>
<div class="stat"><div class="k">一致跳过推送</div><div class="v" id="sSkipped">—</div></div>
<div class="stat"><div class="k">累计失败次数</div><div class="v" id="sFails">—</div></div> <div class="stat"><div class="k">累计失败次数</div><div class="v" id="sFails">—</div></div>
<div class="stat"><div class="k">失败重试</div><div class="v" id="sRetry">—</div></div> <div class="stat"><div class="k">失败重试</div><div class="v" id="sRetry">—</div></div>
<div class="stat"><div class="k">镜像总占用</div><div class="v" id="sSize">—</div></div> <div class="stat"><div class="k">镜像总占用</div><div class="v" id="sSize">—</div></div>
@@ -1175,9 +1286,13 @@ PAGE_STATUS = """
<h2 style="margin:0">项目明细</h2> <h2 style="margin:0">项目明细</h2>
<span class="tip" id="sTarget"></span> <span class="tip" id="sTarget"></span>
</div> </div>
<div class="row" style="margin:10px 0 12px">
<div class="tip" id="cmpTip" style="flex:1"></div>
<button class="b-mini" id="btnCheck" onclick="checkAll()">🔍 核对 GitHub 实况</button>
</div>
<div style="overflow-x:auto"> <div style="overflow-x:auto">
<table class="resp"> <table class="resp">
<thead><tr><th>项目</th><th>状态</th><th>默认分支</th><th>分支/标签</th><th>最近推送同步</th><th>最新提交</th><th>大小</th><th></th></tr></thead> <thead><tr><th>项目</th><th>状态</th><th>默认分支</th><th>分支/标签</th><th>最近推送同步</th><th>GitHub 核对</th><th>最新提交</th><th>大小</th><th></th></tr></thead>
<tbody id="tb"></tbody> <tbody id="tb"></tbody>
</table> </table>
</div> </div>
@@ -1196,6 +1311,7 @@ async function refresh(){
document.getElementById('sNext').textContent=s.enabled?(s.next_in==null?'—':s.next_in+' 秒'):'已暂停'; document.getElementById('sNext').textContent=s.enabled?(s.next_in==null?'—':s.next_in+' 秒'):'已暂停';
document.getElementById('sChecks').textContent=s.stats.total_checks; document.getElementById('sChecks').textContent=s.stats.total_checks;
document.getElementById('sPushes').textContent=s.stats.total_pushes; 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; document.getElementById('sFails').textContent=s.stats.total_failures;
const rt=s.retry||{}; const rt=s.retry||{};
document.getElementById('sRetry').textContent=(rt.pending&&rt.pending.length) 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('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?'在线 ✓':'离线 ✗'); 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 上的分支和提交号:一致就跳过,不同才推送。'
+'当前模式:<b>'+(vg?'每轮都核对(严格)':'仅本地有新提交时核对(省流量)')+'</b>,可在<a href="settings">设置</a>里切换。';
document.getElementById('tb').innerHTML=s.repos.map(r=>{ document.getElementById('tb').innerHTML=s.repos.map(r=>{
const att=(rt.attempts||{})[r.name]; const att=(rt.attempts||{})[r.name];
const stTxt=r.status==='已同步'?'✓ 已同步':(att!=null?('⏳ 重试中 '+att+'/'+(rt.max||5)):'⏳ 未同步'); const stTxt=r.status==='已同步'?'✓ 已同步':(att!=null?('⏳ 重试中 '+att+'/'+(rt.max||5)):'⏳ 未同步');
const stCls=r.status==='已同步'?'ok':'warn'; const stCls=r.status==='已同步'?'ok':'warn';
const st=`<td class="${stCls}">${stTxt}</td>`; const st=`<td class="${stCls}">${stTxt}</td>`;
const cp=r.compare;
let cpHtml='<span class="tip">未核对</span>';
if(cp){
const map={same:['✓ 一致','ok'],diff:['≠ 不同','warn'],unknown:['? 未知','warn']};
const mm=map[cp.verdict]||['? 未知','warn'];
cpHtml=`<span class="${mm[1]}" title="${esc(cp.detail)} ${esc(cp.at)}">${mm[0]}</span>`
+`<div class="commit">${esc(cp.at)}</div>`;
}
const c=r.last_commit?`<div class="commit">${esc(r.last_commit.hash)} ${esc(r.last_commit.msg)} · ${esc(r.last_commit.author)} · ${esc(r.last_commit.time)}</div>`:'<div class="commit">—</div>'; const c=r.last_commit?`<div class="commit">${esc(r.last_commit.hash)} ${esc(r.last_commit.msg)} · ${esc(r.last_commit.author)} · ${esc(r.last_commit.time)}</div>`:'<div class="commit">—</div>';
return `<tr><td data-label="项目"><b>${esc(r.name)}</b></td>${st}<td data-label="默认分支">${esc(r.head||'—')}</td><td data-label="分支/标签">${r.branches} / ${r.tags}</td><td data-label="最近推送同步">${r.last_push||'—'}</td><td data-label="最新提交">${c}</td><td data-label="大小">${r.size_mb} MB</td><td data-label="操作"><button class="b-mini" onclick="syncOne('${esc(r.name)}')">同步</button></td></tr>`; return `<tr><td data-label="项目"><b>${esc(r.name)}</b></td>${st}<td data-label="默认分支">${esc(r.head||'—')}</td><td data-label="分支/标签">${r.branches} / ${r.tags}</td><td data-label="最近推送同步">${r.last_push||'—'}</td><td data-label="GitHub 核对">${cpHtml}</td><td data-label="最新提交">${c}</td><td data-label="大小">${r.size_mb} MB</td><td data-label="操作"><button class="b-mini" onclick="checkOne('${esc(r.name)}')">核对</button> <button class="b-mini" onclick="syncOne('${esc(r.name)}')">同步</button></td></tr>`;
}).join('')||'<tr><td colspan="8" style="color:#646a73">还没有同步项目,去<a href="manage">项目管理</a>添加</td></tr>'; }).join('')||'<tr><td colspan="9" style="color:#646a73">还没有同步项目,去<a href="manage">项目管理</a>添加</td></tr>';
}catch(e){document.getElementById('tb').innerHTML='<tr><td colspan="8">刷新失败: '+e+'</td></tr>'} }catch(e){document.getElementById('tb').innerHTML='<tr><td colspan="9">刷新失败: '+e+'</td></tr>'}
}
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){ 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(); 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 = """
<div class="swsub">需要当前账号已配置 Token;关闭后添加不存在的仓库会提示手动创建</div> <div class="swsub">需要当前账号已配置 Token;关闭后添加不存在的仓库会提示手动创建</div>
</div> </div>
</div> </div>
<div class="optrow">
<label class="switch"><input type="checkbox" id="swVerify" onchange="saveVerify()"><span class="slider"></span></label>
<div class="opttext">
<div class="swlabel">每轮都核对 GitHub 实况(严格模式)</div>
<div class="swsub">推送前先读 GitHub 上的分支和提交号,一致就跳过(推送前后都会核对)。关闭时只在本地有新提交时才访问 GitHub,更省流量;开启后即使你手动改过 GitHub 也能立刻发现并纠正</div>
</div>
</div>
<div class="optrow"> <div class="optrow">
<div class="numwrap"> <div class="numwrap">
<input id="syncInterval" type="number" min="5" max="86400" step="1" class="numin" onchange="saveInterval()"> <input id="syncInterval" type="number" min="5" max="86400" step="1" class="numin" onchange="saveInterval()">
@@ -1404,6 +1550,7 @@ async function refresh(){
document.getElementById('giteaUrlTip').textContent='「打开 Gitea」按钮地址:'+(s.gitea_url||'—')+'(随访问地址自动适配);密码只保存在 NAS 本地,仅用于读取私有仓库。'; document.getElementById('giteaUrlTip').textContent='「打开 Gitea」按钮地址:'+(s.gitea_url||'—')+'(随访问地址自动适配);密码只保存在 NAS 本地,仅用于读取私有仓库。';
document.getElementById('openUrl').value=s.open_url||''; document.getElementById('openUrl').value=s.open_url||'';
document.getElementById('swAuto').checked=!!s.auto_create; document.getElementById('swAuto').checked=!!s.auto_create;
document.getElementById('swVerify').checked=!!s.verify_github;
const iv=document.getElementById('syncInterval'); const iv=document.getElementById('syncInterval');
iv.value=s.interval||10;iv.min=s.min_interval||5;iv.max=s.max_interval||86400; 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)+' 秒),改动即生效'; 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(); 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(); 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){ function fmtInterval(s){
s=parseInt(s,10)||0; s=parseInt(s,10)||0;
if(s>=3600)return (s/3600)+' 小时'; if(s>=3600)return (s/3600)+' 小时';
@@ -1735,6 +1887,7 @@ class Handler(BaseHTTPRequestHandler):
"max_workers_limit": MAX_WORKERS, "max_workers_limit": MAX_WORKERS,
"max_retries": max_retries(), "max_retries": max_retries(),
"retry_interval": RETRY_INTERVAL, "retry_interval": RETRY_INTERVAL,
"verify_github": verify_strict(),
"gitea_host": gitea_host(), "gitea_host": gitea_host(),
"open_url": cfg.get("open_url", ""), "open_url": cfg.get("open_url", ""),
"gitea_url": gitea_ui( "gitea_url": gitea_ui(
@@ -1854,6 +2007,12 @@ class Handler(BaseHTTPRequestHandler):
cfg["max_retries"] = int(data.get("max_retries")) cfg["max_retries"] = int(data.get("max_retries"))
if cfg["max_retries"] != old_r: if cfg["max_retries"] != old_r:
log(f"失败重试次数已调整: {old_r} -> {cfg['max_retries']}") 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() save_settings()
notes = [] notes = []
if new_host is not None: if new_host is not None:
@@ -1971,6 +2130,54 @@ class Handler(BaseHTTPRequestHandler):
self._json({"ok": True, "msg": f"Token 有效 ✓({login})"}) self._json({"ok": True, "msg": f"Token 有效 ✓({login})"})
else: else:
self._json({"ok": False, "msg": f"Token 校验失败: {err}"}) 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": elif path == "/api/repos/sync":
name = data.get("name", "") name = data.get("name", "")
if not valid_name(name): if not valid_name(name):
+1 -1
View File
@@ -1,5 +1,5 @@
appname = GiteaHubSync appname = GiteaHubSync
version = 1.5.6 version = 1.6.0
display_name = Gitea→GitHub 同步助手 display_name = Gitea→GitHub 同步助手
desc = 每次推送到飞牛 Gitea 的代码,10 秒内自动镜像到 GitHub 的 7 个私有仓库。内置控制面板:自动同步开关、立即同步、仓库状态、同步日志,一键打开 Gitea。数据全部保存在 NAS 本地。 desc = 每次推送到飞牛 Gitea 的代码,10 秒内自动镜像到 GitHub 的 7 个私有仓库。内置控制面板:自动同步开关、立即同步、仓库状态、同步日志,一键打开 Gitea。数据全部保存在 NAS 本地。
platform = all platform = all
+10 -2
View File
@@ -21,6 +21,13 @@ NAS 重启自动恢复。控制面板带自动同步开关、立即同步、仓
- **失败自动重试**:同步失败后每隔 1 分钟自动重试失败的仓库,最多 N 次 - **失败自动重试**:同步失败后每隔 1 分钟自动重试失败的仓库,最多 N 次
(默认 5,可调 0~20,0 = 不重试);重试与同步间隔无关,专治网络抖动, (默认 5,可调 0~20,0 = 不重试);重试与同步间隔无关,专治网络抖动,
重试中的仓库在状态页显示「重试中 x/N」 重试中的仓库在状态页显示「重试中 x/N」
- **推送前先核对 GitHub 实况**:每轮同步先读 GitHub 上该仓库**真实的分支和提交号**
(`git ls-remote`),与本地镜像逐条比对——**完全一致就跳过推送**,不同才推;
推送成功后再核对一次,确认 GitHub 真的和本地一致。好处:① 不做无意义推送、省流量;
② 你在 GitHub 上手动删了分支或回退了提交,下一轮就能发现并纠正;
③ 同步记录文件丢了也不会整仓重推一遍。默认「仅本地有新提交时核对」(省流量),
设置页可开 **严格模式**每轮都核对;状态页有「GitHub 核对」列(✓ 一致 / ≠ 不同 / ? 未知)、
单仓库「核对」按钮和「🔍 核对 GitHub 实况」一键全量核对(**只核对不推送**)
- **Gitea 地址可改**:设置页可改同步引擎访问的 Gitea 地址(默认 `http://127.0.0.1:3000`), - **Gitea 地址可改**:设置页可改同步引擎访问的 Gitea 地址(默认 `http://127.0.0.1:3000`),
保存前自动探测连通性;改地址后已有镜像的拉取地址自动更新,「打开 Gitea」按钮随访问地址自适应 保存前自动探测连通性;改地址后已有镜像的拉取地址自动更新,「打开 Gitea」按钮随访问地址自适应
- **自动创建仓库**:Gitea 上的新仓库加入同步时,若 GitHub 上不存在同名仓库, - **自动创建仓库**:Gitea 上的新仓库加入同步时,若 GitHub 上不存在同名仓库,
@@ -88,5 +95,6 @@ cd /vol1/@appshare/fn-deepseek-harness/fnos-gitea-sync
端口直连,私钥随应用打包安装在 NAS 本地 端口直连,私钥随应用打包安装在 NAS 本地
- 从 v1.0.0 升级会自动迁移:旧的同步进度归入「默认(SSH 私钥)」账号,行为不变 - 从 v1.0.0 升级会自动迁移:旧的同步进度归入「默认(SSH 私钥)」账号,行为不变
- 数据文件:`accounts.json`(GitHub 账号,含 Token)、`settings.json` - 数据文件:`accounts.json`(GitHub 账号,含 Token)、`settings.json`
(Gitea 地址/用户名/密码、自动创建开关、同步间隔)、`repos.json`(同步列表)、 (Gitea 地址/用户名/密码、自动创建开关、同步间隔、GitHub 严格核对开关)、
`.last-pushed/<账号>/<仓库>`(每个账号的同步进度) `repos.json`(同步列表)、`.last-pushed/<账号>/<仓库>`(每个账号的同步进度,
仅作「本地有没有新提交」的快速判断;真正的跳过推送以 GitHub 实况比对为准)