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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ funmirror = funmirror.script:funmirror
@@ -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.