funmirror 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- funmirror/__init__.py +1 -0
- funmirror/mirror.py +127 -0
- funmirror/pipeline.py +66 -0
- funmirror/platforms.py +96 -0
- funmirror/script.py +117 -0
- funmirror-0.1.3.dist-info/METADATA +81 -0
- funmirror-0.1.3.dist-info/RECORD +10 -0
- funmirror-0.1.3.dist-info/WHEEL +4 -0
- funmirror-0.1.3.dist-info/entry_points.txt +2 -0
- funmirror-0.1.3.dist-info/licenses/LICENSE +21 -0
funmirror/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
funmirror/mirror.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Single-repo mirror logic: skip-if-unchanged check, then clone + push."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from farlog import get_logger
|
|
12
|
+
|
|
13
|
+
from funmirror import platforms
|
|
14
|
+
|
|
15
|
+
logger = get_logger("funmirror")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class MirrorContext:
|
|
20
|
+
github_org: str
|
|
21
|
+
gitee_org: str
|
|
22
|
+
gitee_token: str
|
|
23
|
+
gitee_key_file: str
|
|
24
|
+
github_token: str = ""
|
|
25
|
+
force: bool = True
|
|
26
|
+
workdir: str = field(default_factory=lambda: tempfile.mkdtemp(prefix="funmirror-"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class MirrorResult:
|
|
31
|
+
repo: str
|
|
32
|
+
status: str # "mirrored" | "skipped" | "failed"
|
|
33
|
+
detail: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _git_env(ctx: MirrorContext) -> Dict[str, str]:
|
|
37
|
+
env = os.environ.copy()
|
|
38
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
39
|
+
env["GIT_SSH_COMMAND"] = (
|
|
40
|
+
f"ssh -i {ctx.gitee_key_file} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes"
|
|
41
|
+
)
|
|
42
|
+
return env
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run(
|
|
46
|
+
cmd: List[str], *, cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None
|
|
47
|
+
):
|
|
48
|
+
return subprocess.run(
|
|
49
|
+
cmd, cwd=cwd, env=env, check=True, capture_output=True, text=True
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _retry(fn, *args, attempts: int = 3, delay: float = 5, **kwargs):
|
|
54
|
+
last_exc: Optional[Exception] = None
|
|
55
|
+
for attempt in range(attempts):
|
|
56
|
+
try:
|
|
57
|
+
return fn(*args, **kwargs)
|
|
58
|
+
except Exception as exc: # noqa: BLE001
|
|
59
|
+
last_exc = exc
|
|
60
|
+
if attempt < attempts - 1:
|
|
61
|
+
logger.warning(f"retry {attempt + 1}/{attempts} after error: {exc}")
|
|
62
|
+
time.sleep(delay * (2**attempt))
|
|
63
|
+
raise last_exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def mirror_one(repo: Dict, ctx: MirrorContext) -> MirrorResult:
|
|
67
|
+
name = repo["name"]
|
|
68
|
+
branch = repo.get("default_branch") or "master"
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
src_sha = platforms.github_branch_sha(
|
|
72
|
+
ctx.github_org, name, branch, ctx.github_token
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if platforms.gitee_repo_exists(ctx.gitee_org, name, ctx.gitee_token):
|
|
76
|
+
dst_sha = platforms.gitee_branch_sha(
|
|
77
|
+
ctx.gitee_org, name, branch, ctx.gitee_token
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
logger.info(f"{name}: doesn't exist on Gitee, creating")
|
|
81
|
+
platforms.gitee_create_repo(ctx.gitee_org, name, ctx.gitee_token)
|
|
82
|
+
dst_sha = None
|
|
83
|
+
|
|
84
|
+
if src_sha and src_sha == dst_sha:
|
|
85
|
+
return MirrorResult(name, "skipped", "up to date")
|
|
86
|
+
|
|
87
|
+
return _clone_and_push(name, ctx, env=_git_env(ctx))
|
|
88
|
+
except Exception as exc: # noqa: BLE001
|
|
89
|
+
logger.error(f"{name}: mirror failed: {exc}")
|
|
90
|
+
return MirrorResult(name, "failed", str(exc))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _clone_and_push(
|
|
94
|
+
name: str, ctx: MirrorContext, *, env: Dict[str, str]
|
|
95
|
+
) -> MirrorResult:
|
|
96
|
+
repo_dir = os.path.join(ctx.workdir, name)
|
|
97
|
+
shutil.rmtree(repo_dir, ignore_errors=True)
|
|
98
|
+
try:
|
|
99
|
+
src_url = f"https://github.com/{ctx.github_org}/{name}.git"
|
|
100
|
+
if ctx.github_token:
|
|
101
|
+
src_url = f"https://x-access-token:{ctx.github_token}@github.com/{ctx.github_org}/{name}.git"
|
|
102
|
+
|
|
103
|
+
_retry(_run, ["git", "clone", "--quiet", src_url, repo_dir], env=env)
|
|
104
|
+
_run(["git", "remote", "set-head", "origin", "-d"], cwd=repo_dir, env=env)
|
|
105
|
+
|
|
106
|
+
rev = _run(
|
|
107
|
+
["git", "rev-list", "-n", "1", "--all"], cwd=repo_dir, env=env
|
|
108
|
+
).stdout.strip()
|
|
109
|
+
if not rev:
|
|
110
|
+
return MirrorResult(name, "skipped", "empty repo")
|
|
111
|
+
|
|
112
|
+
dst_url = f"git@gitee.com:{ctx.gitee_org}/{name}.git"
|
|
113
|
+
_run(["git", "remote", "add", "gitee", dst_url], cwd=repo_dir, env=env)
|
|
114
|
+
push_cmd = [
|
|
115
|
+
"git",
|
|
116
|
+
"push",
|
|
117
|
+
"gitee",
|
|
118
|
+
"refs/remotes/origin/*:refs/heads/*",
|
|
119
|
+
"--tags",
|
|
120
|
+
"--prune",
|
|
121
|
+
]
|
|
122
|
+
if ctx.force:
|
|
123
|
+
push_cmd.append("-f")
|
|
124
|
+
_retry(_run, push_cmd, cwd=repo_dir, env=env)
|
|
125
|
+
return MirrorResult(name, "mirrored")
|
|
126
|
+
finally:
|
|
127
|
+
shutil.rmtree(repo_dir, ignore_errors=True)
|
funmirror/pipeline.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Parallel repo mirroring built on funworker's producer/processor/consumer pipeline."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
|
|
5
|
+
from farlog import get_logger
|
|
6
|
+
from funworker import BaseConsumer, BaseProcessor, BaseProducer, Pipeline
|
|
7
|
+
|
|
8
|
+
from funmirror.mirror import MirrorContext, MirrorResult, mirror_one
|
|
9
|
+
|
|
10
|
+
logger = get_logger("funmirror")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RepoProducer(BaseProducer):
|
|
14
|
+
def __init__(self, *args, repos: List[Dict], **kwargs):
|
|
15
|
+
super().__init__(*args, **kwargs)
|
|
16
|
+
self._iter = iter(repos)
|
|
17
|
+
|
|
18
|
+
def produce(self):
|
|
19
|
+
return next(self._iter)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MirrorProcessor(BaseProcessor):
|
|
23
|
+
def __init__(self, ctx: MirrorContext):
|
|
24
|
+
self.ctx = ctx
|
|
25
|
+
|
|
26
|
+
def process(self, repo: Dict) -> MirrorResult:
|
|
27
|
+
return mirror_one(repo, self.ctx)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ResultConsumer(BaseConsumer):
|
|
31
|
+
def __init__(self, *args, total: int, **kwargs):
|
|
32
|
+
super().__init__(*args, **kwargs)
|
|
33
|
+
self.total = total
|
|
34
|
+
self.done = 0
|
|
35
|
+
self.mirrored: List[str] = []
|
|
36
|
+
self.skipped: List[str] = []
|
|
37
|
+
self.failed: List[str] = []
|
|
38
|
+
|
|
39
|
+
def consume(self, result: MirrorResult) -> None:
|
|
40
|
+
self.done += 1
|
|
41
|
+
detail = f" ({result.detail})" if result.detail else ""
|
|
42
|
+
logger.info(
|
|
43
|
+
f"[{self.done}/{self.total}] {result.repo}: {result.status}{detail}"
|
|
44
|
+
)
|
|
45
|
+
bucket = {
|
|
46
|
+
"mirrored": self.mirrored,
|
|
47
|
+
"skipped": self.skipped,
|
|
48
|
+
"failed": self.failed,
|
|
49
|
+
}
|
|
50
|
+
bucket[result.status].append(result.repo)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_mirror(
|
|
54
|
+
repos: List[Dict], ctx: MirrorContext, *, num_workers: int = 8
|
|
55
|
+
) -> ResultConsumer:
|
|
56
|
+
"""Mirror `repos` in parallel and return the consumer holding the final tallies."""
|
|
57
|
+
pipeline = Pipeline.build(
|
|
58
|
+
producer_cls=RepoProducer,
|
|
59
|
+
processor=lambda: MirrorProcessor(ctx),
|
|
60
|
+
consumer_cls=ResultConsumer,
|
|
61
|
+
num_workers=num_workers,
|
|
62
|
+
producer_kwargs={"repos": repos},
|
|
63
|
+
consumer_kwargs={"total": len(repos)},
|
|
64
|
+
)
|
|
65
|
+
pipeline.run()
|
|
66
|
+
return pipeline.consumer
|
funmirror/platforms.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""GitHub / Gitee REST API helpers used by funmirror."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from farlog import get_logger
|
|
9
|
+
|
|
10
|
+
logger = get_logger("funmirror")
|
|
11
|
+
|
|
12
|
+
GITHUB_API = "https://api.github.com"
|
|
13
|
+
GITEE_API = "https://gitee.com/api/v5"
|
|
14
|
+
|
|
15
|
+
_session = requests.Session()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _github_headers(token: str) -> Dict[str, str]:
|
|
19
|
+
return {"Authorization": f"token {token}"} if token else {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_github_repos(org: str, token: str = "", per_page: int = 100) -> List[Dict]:
|
|
23
|
+
"""List all repos in a GitHub org, with their default branch."""
|
|
24
|
+
repos: List[Dict] = []
|
|
25
|
+
page = 1
|
|
26
|
+
while True:
|
|
27
|
+
resp = _session.get(
|
|
28
|
+
f"{GITHUB_API}/orgs/{org}/repos",
|
|
29
|
+
params={"type": "all", "per_page": per_page, "page": page},
|
|
30
|
+
headers=_github_headers(token),
|
|
31
|
+
timeout=30,
|
|
32
|
+
)
|
|
33
|
+
resp.raise_for_status()
|
|
34
|
+
items = resp.json()
|
|
35
|
+
if not items:
|
|
36
|
+
break
|
|
37
|
+
repos.extend(
|
|
38
|
+
{"name": item["name"], "default_branch": item["default_branch"]}
|
|
39
|
+
for item in items
|
|
40
|
+
)
|
|
41
|
+
page += 1
|
|
42
|
+
return repos
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def github_default_branch(org: str, repo: str, token: str = "") -> Optional[str]:
|
|
46
|
+
resp = _session.get(
|
|
47
|
+
f"{GITHUB_API}/repos/{org}/{repo}", headers=_github_headers(token), timeout=30
|
|
48
|
+
)
|
|
49
|
+
if resp.status_code != 200:
|
|
50
|
+
return None
|
|
51
|
+
return resp.json().get("default_branch")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def github_branch_sha(
|
|
55
|
+
org: str, repo: str, branch: str, token: str = ""
|
|
56
|
+
) -> Optional[str]:
|
|
57
|
+
resp = _session.get(
|
|
58
|
+
f"{GITHUB_API}/repos/{org}/{repo}/branches/{branch}",
|
|
59
|
+
headers=_github_headers(token),
|
|
60
|
+
timeout=30,
|
|
61
|
+
)
|
|
62
|
+
if resp.status_code != 200:
|
|
63
|
+
return None
|
|
64
|
+
return resp.json().get("commit", {}).get("sha")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def gitee_repo_exists(org: str, repo: str, token: str) -> bool:
|
|
68
|
+
resp = _session.get(
|
|
69
|
+
f"{GITEE_API}/repos/{org}/{repo}", params={"access_token": token}, timeout=30
|
|
70
|
+
)
|
|
71
|
+
return resp.status_code == 200
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def gitee_branch_sha(org: str, repo: str, branch: str, token: str) -> Optional[str]:
|
|
75
|
+
resp = _session.get(
|
|
76
|
+
f"{GITEE_API}/repos/{org}/{repo}/branches/{branch}",
|
|
77
|
+
params={"access_token": token},
|
|
78
|
+
timeout=30,
|
|
79
|
+
)
|
|
80
|
+
if resp.status_code != 200:
|
|
81
|
+
return None
|
|
82
|
+
return resp.json().get("commit", {}).get("sha")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def gitee_create_repo(org: str, repo: str, token: str) -> None:
|
|
86
|
+
resp = _session.post(
|
|
87
|
+
f"{GITEE_API}/orgs/{org}/repos",
|
|
88
|
+
data={"name": repo, "access_token": token},
|
|
89
|
+
timeout=30,
|
|
90
|
+
)
|
|
91
|
+
if resp.status_code != 201:
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
f"failed to create {org}/{repo} on Gitee: {resp.status_code} {resp.text}"
|
|
94
|
+
)
|
|
95
|
+
# Gitee needs a moment before the new repo is ready to receive a push.
|
|
96
|
+
time.sleep(2)
|
funmirror/script.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""funmirror CLI: mirror a GitHub org to a Gitee org."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from farlog import get_logger
|
|
9
|
+
|
|
10
|
+
from funmirror import platforms
|
|
11
|
+
from funmirror.mirror import MirrorContext
|
|
12
|
+
from funmirror.pipeline import run_mirror
|
|
13
|
+
|
|
14
|
+
logger = get_logger("funmirror")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _split_names(value: str) -> List[str]:
|
|
18
|
+
return [n.strip() for n in value.split(",") if n.strip()]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_repo_list(args: argparse.Namespace) -> List[dict]:
|
|
22
|
+
if args.repo_names:
|
|
23
|
+
names = _split_names(args.repo_names)
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
"name": name,
|
|
27
|
+
"default_branch": platforms.github_default_branch(
|
|
28
|
+
args.github_org, name, args.github_token
|
|
29
|
+
)
|
|
30
|
+
or "master",
|
|
31
|
+
}
|
|
32
|
+
for name in names
|
|
33
|
+
]
|
|
34
|
+
return platforms.list_github_repos(args.github_org, args.github_token)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _mirror(args: argparse.Namespace) -> int:
|
|
38
|
+
repos = _build_repo_list(args)
|
|
39
|
+
if not repos:
|
|
40
|
+
logger.warning("No repos to mirror")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
ctx = MirrorContext(
|
|
44
|
+
github_org=args.github_org,
|
|
45
|
+
gitee_org=args.gitee_org,
|
|
46
|
+
gitee_token=args.gitee_token,
|
|
47
|
+
gitee_key_file=args.gitee_key_file,
|
|
48
|
+
github_token=args.github_token,
|
|
49
|
+
force=args.force,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
consumer = run_mirror(repos, ctx, num_workers=args.workers)
|
|
53
|
+
|
|
54
|
+
summary = (
|
|
55
|
+
f"Mirrored {len(consumer.mirrored)}, skipped {len(consumer.skipped)}, "
|
|
56
|
+
f"failed {len(consumer.failed)} (total {len(repos)})"
|
|
57
|
+
)
|
|
58
|
+
logger.info(summary)
|
|
59
|
+
|
|
60
|
+
step_summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
61
|
+
if step_summary:
|
|
62
|
+
with open(step_summary, "a") as f:
|
|
63
|
+
f.write(summary + "\n")
|
|
64
|
+
|
|
65
|
+
github_output = os.environ.get("GITHUB_OUTPUT")
|
|
66
|
+
if github_output:
|
|
67
|
+
with open(github_output, "a") as f:
|
|
68
|
+
f.write(f"mirrored={len(consumer.mirrored)}\n")
|
|
69
|
+
f.write(f"skipped={len(consumer.skipped)}\n")
|
|
70
|
+
f.write(f"failed={len(consumer.failed)}\n")
|
|
71
|
+
f.write(f"total={len(repos)}\n")
|
|
72
|
+
|
|
73
|
+
if consumer.failed:
|
|
74
|
+
logger.error(f"Failed: {', '.join(consumer.failed)}")
|
|
75
|
+
return 1
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parser() -> argparse.ArgumentParser:
|
|
80
|
+
parser = argparse.ArgumentParser(
|
|
81
|
+
prog="funmirror", description="Mirror GitHub org repos to Gitee"
|
|
82
|
+
)
|
|
83
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
84
|
+
|
|
85
|
+
mirror = commands.add_parser(
|
|
86
|
+
"mirror", help="mirror a GitHub org's repos to a Gitee org"
|
|
87
|
+
)
|
|
88
|
+
mirror.add_argument("--github-org", required=True)
|
|
89
|
+
mirror.add_argument("--gitee-org", required=True)
|
|
90
|
+
mirror.add_argument("--gitee-token", required=True)
|
|
91
|
+
mirror.add_argument("--gitee-key-file", required=True)
|
|
92
|
+
mirror.add_argument("--github-token", default="")
|
|
93
|
+
mirror.add_argument(
|
|
94
|
+
"--repo-names", default="", help="comma-separated; empty means all repos"
|
|
95
|
+
)
|
|
96
|
+
mirror.add_argument("--workers", type=int, default=8)
|
|
97
|
+
mirror.add_argument("--force", dest="force", action="store_true", default=True)
|
|
98
|
+
mirror.add_argument("--no-force", dest="force", action="store_false")
|
|
99
|
+
mirror.set_defaults(handler=_mirror)
|
|
100
|
+
|
|
101
|
+
return parser
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def funmirror() -> int:
|
|
105
|
+
args = _parser().parse_args()
|
|
106
|
+
try:
|
|
107
|
+
return args.handler(args)
|
|
108
|
+
except KeyboardInterrupt:
|
|
109
|
+
logger.warning("Interrupted")
|
|
110
|
+
return 1
|
|
111
|
+
except Exception as exc: # noqa: BLE001
|
|
112
|
+
logger.exception(f"funmirror failed: {exc}")
|
|
113
|
+
return 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
sys.exit(funmirror())
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: funmirror
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Mirror GitHub organization repos to Gitee, in parallel, skipping repos that are already up to date
|
|
5
|
+
Project-URL: Organization, https://github.com/farfarfun
|
|
6
|
+
Project-URL: Repository, https://github.com/farfarfun/funmirror
|
|
7
|
+
Project-URL: Releases, https://github.com/farfarfun/funmirror/releases
|
|
8
|
+
Author-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
|
|
9
|
+
Maintainer-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Requires-Dist: farlog>=1.1.8
|
|
14
|
+
Requires-Dist: funworker>=0.0.1
|
|
15
|
+
Requires-Dist: requests>=2.32.5
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# funmirror
|
|
21
|
+
|
|
22
|
+
Mirror every repo in a GitHub organization to a Gitee organization, in
|
|
23
|
+
parallel, skipping repos whose default branch hasn't changed since the last
|
|
24
|
+
run.
|
|
25
|
+
|
|
26
|
+
Built for [`farfarfun-action/mirror-to-gitee`](https://github.com/farfarfun-action/mirror-to-gitee),
|
|
27
|
+
which is a thin wrapper around this package's CLI. It reproduces the core
|
|
28
|
+
behavior of [`Yikun/hub-mirror-action`](https://github.com/Yikun/hub-mirror-action)
|
|
29
|
+
(clone from GitHub, force-push `refs/remotes/origin/*:refs/heads/*` plus tags
|
|
30
|
+
to Gitee) without depending on that action, and adds two things it doesn't
|
|
31
|
+
have:
|
|
32
|
+
|
|
33
|
+
- **Parallelism** — repos are mirrored concurrently via a
|
|
34
|
+
[`funworker`](https://github.com/farfarfun/funworker) pipeline
|
|
35
|
+
(`--workers`, default 8).
|
|
36
|
+
- **Skip-if-unchanged** — before cloning anything, the latest commit sha of
|
|
37
|
+
the source and destination default branch is compared; if they already
|
|
38
|
+
match, the repo is skipped entirely.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install "git+https://github.com/farfarfun/funmirror.git@v0.1.0"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
funmirror mirror \
|
|
50
|
+
--github-org my-org \
|
|
51
|
+
--gitee-org my-org \
|
|
52
|
+
--gitee-token "$GITEE_TOKEN" \
|
|
53
|
+
--gitee-key-file ~/.ssh/gitee_deploy_key \
|
|
54
|
+
--github-token "$GITHUB_TOKEN" \
|
|
55
|
+
--repo-names repo-a,repo-b \
|
|
56
|
+
--workers 8
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`--repo-names` is optional; if omitted, every repo in `--github-org` is
|
|
60
|
+
mirrored (requires `--github-token` to list them).
|
|
61
|
+
|
|
62
|
+
Exit code is `1` if any repo failed to mirror; a one-line summary
|
|
63
|
+
(`Mirrored X, skipped Y, failed Z (total N)`) is printed and, if
|
|
64
|
+
`GITHUB_STEP_SUMMARY` is set, appended to it.
|
|
65
|
+
|
|
66
|
+
## How a single repo is mirrored
|
|
67
|
+
|
|
68
|
+
1. Look up the latest commit sha of the source default branch on GitHub.
|
|
69
|
+
2. If the repo doesn't exist yet on Gitee, create it; otherwise look up the
|
|
70
|
+
latest commit sha of the same branch on Gitee.
|
|
71
|
+
3. If both shas match, skip — nothing to do.
|
|
72
|
+
4. Otherwise `git clone` from GitHub, then `git push` (force by default)
|
|
73
|
+
`refs/remotes/origin/*:refs/heads/*` plus tags to Gitee over SSH, with
|
|
74
|
+
retries.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install -e ".[dev]"
|
|
80
|
+
pytest
|
|
81
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
funmirror/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
|
|
2
|
+
funmirror/mirror.py,sha256=5t_8Dn1SsSEg3FGU8buP_-0IHn1QrLCAWRl349nVSNg,3946
|
|
3
|
+
funmirror/pipeline.py,sha256=sQjLCdhaXEflTSjRUZqaKM-x-VGNWd2wIo3sOQK9Edo,2014
|
|
4
|
+
funmirror/platforms.py,sha256=XpAVamePzpdIbdQGk4FZGyTCRB6qoLVvRLMb6SH-mpM,2805
|
|
5
|
+
funmirror/script.py,sha256=eZ_5kL9gqLxQ5sL9osQfNYGu4wlcHu3ru2_8dJ_R-Cg,3552
|
|
6
|
+
funmirror-0.1.3.dist-info/METADATA,sha256=NG_M4htSjLC3wEJzKoUHVvWN_WVPTs0FZmaa53eYsSc,2838
|
|
7
|
+
funmirror-0.1.3.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
8
|
+
funmirror-0.1.3.dist-info/entry_points.txt,sha256=ejpSk3gplMKiN6RrPi_L_vRJhU7786GMRd9nIrQkrV8,57
|
|
9
|
+
funmirror-0.1.3.dist-info/licenses/LICENSE,sha256=BvvS-yeQjeaYgHQW2Vh7Msedpzoc-r7mnjNCpqUUbgM,1066
|
|
10
|
+
funmirror-0.1.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 farfarfun
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|