- 每任务一目录结构,统一开关与日志 - cronctl CLI:enable/disable/toggle/status/run/sync-cron - 公共库 lib/shumengya_cron:runner/manager/notify/ssh - _template 示例任务(hello world + 复制模板) - webui 管理面板:FastAPI 后端 + 文档式响应式前端
203 lines
7.3 KiB
Python
203 lines
7.3 KiB
Python
"""
|
||
Gitea 仓库同步:配置见本目录 repos.json。
|
||
|
||
同步逻辑:先 fetch 检查远端是否有新提交,有则 fast-forward merge,无则跳过,
|
||
不会无谓拉取;仓库依次串行处理,每条间隔 SYNC_DELAY_SECONDS 秒。
|
||
|
||
env 变量:
|
||
BASE_DIR (已废弃,local 字段现在必填)
|
||
SYNC_DELAY_SECONDS 每个仓库处理间隔秒数(默认 2)
|
||
GIT_SSH_COMMAND git SSH 参数
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys as _sys, pathlib as _pl
|
||
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[1] / "lib"))
|
||
del _sys, _pl
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from shumengya_cron.notify import TaskResult, send_task_summary
|
||
from shumengya_cron.runner import (
|
||
TaskContext,
|
||
acquire_cron_lock,
|
||
append_raw_to_log,
|
||
task_is_disabled,
|
||
task_logging,
|
||
)
|
||
|
||
|
||
def _load_repos(path: Path) -> list[tuple[str, Path]]:
|
||
"""加载 repos.json,local 字段必填,缺失的条目直接跳过。"""
|
||
if not path.is_file():
|
||
return []
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
result: list[tuple[str, Path]] = []
|
||
for item in data:
|
||
owner_repo = item.get("repo", "").strip()
|
||
local_s = item.get("local", "").strip()
|
||
if not owner_repo or not local_s:
|
||
continue
|
||
if "/" not in owner_repo:
|
||
owner_repo = f"shumengya/{owner_repo}"
|
||
result.append((owner_repo, Path(local_s)))
|
||
return result
|
||
|
||
|
||
def _git(args: list[str], *, cwd: Path | None = None) -> tuple[int, str]:
|
||
r = subprocess.run(
|
||
["git", *args],
|
||
cwd=str(cwd) if cwd else None,
|
||
capture_output=True,
|
||
text=True,
|
||
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
||
)
|
||
return r.returncode, ((r.stdout or "") + (r.stderr or "")).strip()
|
||
|
||
|
||
def run(ctx: TaskContext) -> int:
|
||
if task_is_disabled(ctx):
|
||
return 0
|
||
|
||
os.environ.setdefault("HOME", "/root")
|
||
os.environ.setdefault(
|
||
"GIT_SSH_COMMAND",
|
||
"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
|
||
)
|
||
|
||
repos_file = ctx.task_dir / "repos.json"
|
||
sync_delay = int(os.environ.get("SYNC_DELAY_SECONDS", "2"))
|
||
start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
result = TaskResult()
|
||
|
||
def sync_one(owner_repo: str, dir_path: Path, log) -> None:
|
||
parts = owner_repo.split("/")
|
||
owner, repo = parts[0], parts[-1]
|
||
remote_url = f"ssh://git@git.shumengya.top:8022/{owner}/{repo}.git"
|
||
|
||
# ── 仓库不存在:直接 clone ───────────────────────────
|
||
if not (dir_path / ".git").is_dir():
|
||
if dir_path.exists():
|
||
log(f"[{owner_repo}] 路径已存在但不是 git 仓库,跳过:{dir_path}")
|
||
result.add_skip(f"{owner_repo}:not_git")
|
||
return
|
||
dir_path.parent.mkdir(parents=True, exist_ok=True)
|
||
log(f"[{owner_repo}] clone -> {dir_path}")
|
||
rc, out = _git(["clone", remote_url, str(dir_path)])
|
||
if out:
|
||
append_raw_to_log(ctx.log_file, out)
|
||
if rc == 0:
|
||
log(f"[{owner_repo}] clone OK")
|
||
result.add_ok(f"{owner_repo}:clone")
|
||
else:
|
||
log(f"[{owner_repo}] clone 失败,请检查 SSH/权限/网络。")
|
||
result.add_fail(f"{owner_repo}:clone_failed")
|
||
return
|
||
|
||
# ── 仓库已存在:fetch → 检查差异 → merge ─────────────
|
||
|
||
# 确认当前分支
|
||
rc, branch = _git(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd=dir_path)
|
||
branch = branch.splitlines()[0].strip() if branch else ""
|
||
if rc != 0 or not branch:
|
||
log(f"[{owner_repo}] detached HEAD,跳过。")
|
||
result.add_skip(f"{owner_repo}:detached")
|
||
return
|
||
|
||
# 找远端名并更新 URL
|
||
rc, remote_name = _git(["config", "--get", f"branch.{branch}.remote"], cwd=dir_path)
|
||
remote_name = remote_name.splitlines()[0].strip() if remote_name else ""
|
||
if not remote_name:
|
||
rc2, rn = _git(["remote"], cwd=dir_path)
|
||
remote_name = rn.splitlines()[0].strip() if rc2 == 0 and rn else ""
|
||
if not remote_name:
|
||
log(f"[{owner_repo}] 未找到远端名,跳过。")
|
||
result.add_fail(f"{owner_repo}:no_remote")
|
||
return
|
||
|
||
_git(["remote", "set-url", remote_name, remote_url], cwd=dir_path)
|
||
|
||
# 工作区必须干净
|
||
rc, dirty = _git(["status", "--porcelain"], cwd=dir_path)
|
||
if dirty:
|
||
log(f"[{owner_repo}] 工作区有改动,跳过(避免覆盖本地改动)。")
|
||
result.add_skip(f"{owner_repo}:dirty")
|
||
return
|
||
|
||
# fetch 远端最新
|
||
rc, fetch_out = _git(["fetch", "--prune", remote_name, branch], cwd=dir_path)
|
||
if fetch_out:
|
||
append_raw_to_log(ctx.log_file, fetch_out)
|
||
if rc != 0:
|
||
log(f"[{owner_repo}] fetch 失败,跳过。")
|
||
result.add_fail(f"{owner_repo}:fetch_failed")
|
||
return
|
||
|
||
# 检查是否有新提交
|
||
rc, count = _git(["rev-list", "HEAD..FETCH_HEAD", "--count"], cwd=dir_path)
|
||
if rc == 0 and count.strip() == "0":
|
||
log(f"[{owner_repo}] 已是最新,跳过。")
|
||
result.add_skip(f"{owner_repo}:already_latest")
|
||
return
|
||
|
||
# 有更新,fast-forward merge
|
||
log(f"[{owner_repo}] 发现 {count.strip()} 个新提交,合并中…")
|
||
rc, merge_out = _git(["merge", "--ff-only", "FETCH_HEAD"], cwd=dir_path)
|
||
if merge_out:
|
||
append_raw_to_log(ctx.log_file, merge_out)
|
||
if rc == 0:
|
||
log(f"[{owner_repo}] OK")
|
||
result.add_ok(f"{owner_repo}:updated")
|
||
else:
|
||
log(f"[{owner_repo}] merge 失败,请手动检查:git -C {dir_path} merge --ff-only FETCH_HEAD")
|
||
result.add_fail(f"{owner_repo}:merge_failed")
|
||
|
||
with task_logging(ctx) as log:
|
||
with acquire_cron_lock(
|
||
ctx.lock_file,
|
||
busy_message=os.environ.get("CRON_LOCK_BUSY_MSG", "已有同步任务在运行,跳过本次。"),
|
||
log=log,
|
||
) as locked:
|
||
if not locked:
|
||
return 0
|
||
|
||
log("start")
|
||
log(f"repos_file={repos_file}")
|
||
log(f"sync_delay_seconds={sync_delay}")
|
||
|
||
if not repos_file.is_file():
|
||
log(f"仓库列表文件不存在:{repos_file}")
|
||
result.add_fail("repos_file:missing")
|
||
send_task_summary(ctx, result, start_time, log=log)
|
||
return 1
|
||
|
||
repo_items = _load_repos(repos_file)
|
||
log(f"共 {len(repo_items)} 个仓库,依次处理")
|
||
|
||
for idx, (owner_repo, dir_path) in enumerate(repo_items):
|
||
sync_one(owner_repo, dir_path, log)
|
||
if sync_delay > 0 and idx < len(repo_items) - 1:
|
||
time.sleep(sync_delay)
|
||
|
||
log("end")
|
||
send_task_summary(ctx, result, start_time, log=log)
|
||
|
||
return 0 if result.success else 1
|
||
|
||
|
||
def main() -> int:
|
||
import pathlib
|
||
task_id = pathlib.Path(__file__).parent.name
|
||
ctx = TaskContext.from_task_id(task_id)
|
||
return run(ctx)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|