Files
sproutclaw-cron/smallmengya-gitea-repo-sync/run.py
shumengya 6c2db2dfa3 feat: init sproutclaw-cron 定时任务管理框架
- 每任务一目录结构,统一开关与日志
- cronctl CLI:enable/disable/toggle/status/run/sync-cron
- 公共库 lib/shumengya_cron:runner/manager/notify/ssh
- _template 示例任务(hello world + 复制模板)
- webui 管理面板:FastAPI 后端 + 文档式响应式前端
2026-06-24 16:19:53 +08:00

203 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.jsonlocal 字段必填,缺失的条目直接跳过。"""
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())