github-repository-sync 0.0.1__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.
- github_repository_sync-0.0.1.dist-info/METADATA +105 -0
- github_repository_sync-0.0.1.dist-info/RECORD +6 -0
- github_repository_sync-0.0.1.dist-info/WHEEL +5 -0
- github_repository_sync-0.0.1.dist-info/entry_points.txt +2 -0
- github_repository_sync-0.0.1.dist-info/top_level.txt +1 -0
- github_repository_sync.py +396 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: github-repository-sync
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Find and clone all repositories for a GitHub organization.
|
|
5
|
+
Author: Kaizten Analytics
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# GitHub Organization Repository Sync
|
|
13
|
+
|
|
14
|
+
`github-repository-sync.py` finds all repositories for a GitHub organization and checks whether they already exist locally under a recursive search root.
|
|
15
|
+
|
|
16
|
+
Repositories that are missing locally are cloned into `<root>/<org>/<repo>` by default. The script detects existing local repositories by inspecting their Git remotes, so repositories can be located in nested folders.
|
|
17
|
+
|
|
18
|
+
The script uses named arguments only. There are no positional arguments.
|
|
19
|
+
|
|
20
|
+
## Dependencies
|
|
21
|
+
|
|
22
|
+
- Python 3.10 or newer
|
|
23
|
+
- Git
|
|
24
|
+
- GitHub CLI (`gh`), recommended for private repositories
|
|
25
|
+
|
|
26
|
+
Authentication options:
|
|
27
|
+
|
|
28
|
+
- Use an authenticated GitHub CLI session with `gh auth login`.
|
|
29
|
+
- Or set `GITHUB_TOKEN` or `GH_TOKEN`.
|
|
30
|
+
- Or pass a token with `--token`.
|
|
31
|
+
|
|
32
|
+
Without authentication, GitHub only returns public repositories.
|
|
33
|
+
|
|
34
|
+
## How to use
|
|
35
|
+
|
|
36
|
+
Run the script with the organization name:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
./github-repository-sync.py --org kaizten
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
By default, the script searches recursively under `~` and clones missing
|
|
43
|
+
repositories into `~/kaizten`.
|
|
44
|
+
|
|
45
|
+
Use `--dry-run` to preview the missing repositories and clone destinations
|
|
46
|
+
without cloning anything:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
./github-repository-sync.py --org kaizten --dry-run
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use `--root` to search and clone under a different directory:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
./github-repository-sync.py --org kaizten --root /home/christopher
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Use `--clone-dir` to clone missing repositories somewhere other than
|
|
59
|
+
`<root>/<org>`:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
./github-repository-sync.py --org kaizten --clone-dir /home/christopher/repos
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If a clone destination already exists and is not empty, the script chooses the
|
|
66
|
+
next available suffixed directory, such as `repo-1`.
|
|
67
|
+
|
|
68
|
+
## Examples
|
|
69
|
+
|
|
70
|
+
Preview missing repositories for `kaizten`:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
./github-repository-sync.py --org kaizten --dry-run
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Clone all missing repositories for `kaizten` under the default root:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
./github-repository-sync.py --org kaizten
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Search under `/home/christopher` and clone missing repositories under
|
|
83
|
+
`/home/christopher/kaizten`:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
./github-repository-sync.py --org kaizten --root /home/christopher
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Clone using SSH URLs:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
./github-repository-sync.py --org kaizten --protocol ssh
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Skip archived repositories:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
./github-repository-sync.py --org kaizten --exclude-archived
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Use an explicit GitHub token:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
./github-repository-sync.py --org kaizten --token YOUR_GITHUB_TOKEN
|
|
105
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
github_repository_sync.py,sha256=iTazVcIaJJMyZEmHXNNzdnbLA1u8pujnFE0MdcHAWcQ,12384
|
|
2
|
+
github_repository_sync-0.0.1.dist-info/METADATA,sha256=aucU8VOJhcexwrT1bNtC2IFIVF3wCVwDqtZqv_KdaTY,2767
|
|
3
|
+
github_repository_sync-0.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
github_repository_sync-0.0.1.dist-info/entry_points.txt,sha256=Jpx2WqZQVFYwUL-wDve5fC-v4l4kUqI4ovX_G59klqk,71
|
|
5
|
+
github_repository_sync-0.0.1.dist-info/top_level.txt,sha256=52t4ReaXYcSgDfrB0PjrelM6Zav7M1bDp_l87TSyGck,23
|
|
6
|
+
github_repository_sync-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
github_repository_sync
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Find and clone all repositories for a GitHub organization.
|
|
3
|
+
|
|
4
|
+
The script discovers local clones recursively under a root directory by
|
|
5
|
+
inspecting git remotes. Any organization repository that is not already present
|
|
6
|
+
locally is cloned into <root>/<org>/<repo> by default.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
GITHUB_API = "https://api.github.com"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class GitHubRepo:
|
|
30
|
+
name: str
|
|
31
|
+
ssh_url: str
|
|
32
|
+
clone_url: str
|
|
33
|
+
archived: bool
|
|
34
|
+
disabled: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class CloneResult:
|
|
39
|
+
repo_name: str
|
|
40
|
+
destination: Path
|
|
41
|
+
status: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_args() -> argparse.Namespace:
|
|
45
|
+
parser = argparse.ArgumentParser(
|
|
46
|
+
description="Recursively find local clones for a GitHub organization and clone missing repositories.",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--org",
|
|
50
|
+
required=True,
|
|
51
|
+
help="GitHub organization name. Example: kaizten",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--root",
|
|
55
|
+
type=Path,
|
|
56
|
+
default=Path.home(),
|
|
57
|
+
help="Directory to search recursively and clone into. Defaults to ~.",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--token",
|
|
61
|
+
default=os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"),
|
|
62
|
+
help="GitHub token. Defaults to GITHUB_TOKEN or GH_TOKEN when set.",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--no-gh-cli",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Do not use the GitHub CLI as an authentication fallback.",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--github-api",
|
|
71
|
+
default=GITHUB_API,
|
|
72
|
+
help="GitHub API base URL. Defaults to https://api.github.com.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--protocol",
|
|
76
|
+
choices=("https", "ssh"),
|
|
77
|
+
default="https",
|
|
78
|
+
help="Clone protocol for missing repositories. Defaults to https.",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--clone-dir",
|
|
82
|
+
type=Path,
|
|
83
|
+
help="Directory where missing repositories are cloned. Defaults to <root>/<org>.",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--include-archived",
|
|
87
|
+
action="store_true",
|
|
88
|
+
help="Include archived repositories. This is the default; kept for compatibility.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--exclude-archived",
|
|
92
|
+
action="store_true",
|
|
93
|
+
help="Skip archived repositories.",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--dry-run",
|
|
97
|
+
action="store_true",
|
|
98
|
+
help="Print what would be cloned without running git clone.",
|
|
99
|
+
)
|
|
100
|
+
return parser.parse_args()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_git(args: list[str], cwd: Path | None = None) -> str:
|
|
104
|
+
completed = subprocess.run(
|
|
105
|
+
["git", *args],
|
|
106
|
+
cwd=cwd,
|
|
107
|
+
check=True,
|
|
108
|
+
text=True,
|
|
109
|
+
stdout=subprocess.PIPE,
|
|
110
|
+
stderr=subprocess.PIPE,
|
|
111
|
+
)
|
|
112
|
+
return completed.stdout.strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_git_repository(path: Path) -> bool:
|
|
116
|
+
return (path / ".git").exists()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def iter_git_repositories(root: Path) -> list[Path]:
|
|
120
|
+
repos: list[Path] = []
|
|
121
|
+
ignored_dir_names = {".git", ".hg", ".svn", "node_modules", ".venv", "venv", "__pycache__"}
|
|
122
|
+
|
|
123
|
+
for current_root, dir_names, _file_names in os.walk(root):
|
|
124
|
+
dir_names[:] = [name for name in dir_names if name not in ignored_dir_names]
|
|
125
|
+
current_path = Path(current_root)
|
|
126
|
+
|
|
127
|
+
if is_git_repository(current_path):
|
|
128
|
+
repos.append(current_path)
|
|
129
|
+
|
|
130
|
+
return repos
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def normalize_remote_url(url: str) -> tuple[str, str] | None:
|
|
134
|
+
url = url.strip()
|
|
135
|
+
if not url:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
scp_match = re.match(r"^git@[^:]+:([^/]+)/(.+?)(?:\.git)?$", url)
|
|
139
|
+
if scp_match:
|
|
140
|
+
return scp_match.group(1).lower(), scp_match.group(2).lower()
|
|
141
|
+
|
|
142
|
+
parsed = urllib.parse.urlparse(url)
|
|
143
|
+
if parsed.scheme and parsed.netloc:
|
|
144
|
+
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
|
145
|
+
if len(parts) >= 2:
|
|
146
|
+
repo_name = re.sub(r"\.git$", "", parts[1], flags=re.IGNORECASE)
|
|
147
|
+
return parts[0].lower(), repo_name.lower()
|
|
148
|
+
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def repository_remotes(repo_path: Path) -> set[tuple[str, str]]:
|
|
153
|
+
try:
|
|
154
|
+
output = run_git(["remote", "-v"], cwd=repo_path)
|
|
155
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
156
|
+
return set()
|
|
157
|
+
|
|
158
|
+
remotes: set[tuple[str, str]] = set()
|
|
159
|
+
for line in output.splitlines():
|
|
160
|
+
parts = line.split()
|
|
161
|
+
if len(parts) >= 2:
|
|
162
|
+
normalized = normalize_remote_url(parts[1])
|
|
163
|
+
if normalized:
|
|
164
|
+
remotes.add(normalized)
|
|
165
|
+
return remotes
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def local_repositories_by_name(root: Path, org: str) -> dict[str, Path]:
|
|
169
|
+
local: dict[str, Path] = {}
|
|
170
|
+
expected_owner = org.lower()
|
|
171
|
+
|
|
172
|
+
for repo_path in iter_git_repositories(root):
|
|
173
|
+
for owner, repo_name in repository_remotes(repo_path):
|
|
174
|
+
if owner == expected_owner:
|
|
175
|
+
local.setdefault(repo_name, repo_path)
|
|
176
|
+
|
|
177
|
+
return local
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def github_request(url: str, token: str | None) -> tuple[list[dict], str | None]:
|
|
181
|
+
headers = {
|
|
182
|
+
"Accept": "application/vnd.github+json",
|
|
183
|
+
"User-Agent": "github-repository-sync",
|
|
184
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
185
|
+
}
|
|
186
|
+
if token:
|
|
187
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
188
|
+
|
|
189
|
+
request = urllib.request.Request(url, headers=headers)
|
|
190
|
+
try:
|
|
191
|
+
with urllib.request.urlopen(request) as response:
|
|
192
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
193
|
+
next_url = parse_next_link(response.headers.get("Link", ""))
|
|
194
|
+
return payload, next_url
|
|
195
|
+
except urllib.error.HTTPError as error:
|
|
196
|
+
message = error.read().decode("utf-8", errors="replace")
|
|
197
|
+
raise RuntimeError(f"GitHub API request failed ({error.code}): {message}") from error
|
|
198
|
+
except urllib.error.URLError as error:
|
|
199
|
+
raise RuntimeError(f"GitHub API request failed: {error.reason}") from error
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def parse_next_link(link_header: str) -> str | None:
|
|
203
|
+
for item in link_header.split(","):
|
|
204
|
+
section = item.strip()
|
|
205
|
+
if 'rel="next"' not in section:
|
|
206
|
+
continue
|
|
207
|
+
match = re.match(r"<([^>]+)>", section)
|
|
208
|
+
if match:
|
|
209
|
+
return match.group(1)
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def fetch_org_repositories(api_base: str, org: str, token: str | None) -> list[GitHubRepo]:
|
|
214
|
+
encoded_org = urllib.parse.quote(org, safe="")
|
|
215
|
+
url: str | None = f"{api_base.rstrip('/')}/orgs/{encoded_org}/repos?per_page=100&type=all"
|
|
216
|
+
repos: list[GitHubRepo] = []
|
|
217
|
+
|
|
218
|
+
while url:
|
|
219
|
+
payload, url = github_request(url, token)
|
|
220
|
+
for item in payload:
|
|
221
|
+
repos.append(
|
|
222
|
+
GitHubRepo(
|
|
223
|
+
name=item["name"],
|
|
224
|
+
ssh_url=item["ssh_url"],
|
|
225
|
+
clone_url=item["clone_url"],
|
|
226
|
+
archived=bool(item.get("archived")),
|
|
227
|
+
disabled=bool(item.get("disabled")),
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return repos
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def fetch_org_repositories_with_gh(org: str) -> list[GitHubRepo]:
|
|
235
|
+
if not shutil.which("gh"):
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
completed = subprocess.run(
|
|
239
|
+
[
|
|
240
|
+
"gh",
|
|
241
|
+
"api",
|
|
242
|
+
"--paginate",
|
|
243
|
+
f"/orgs/{org}/repos?per_page=100&type=all",
|
|
244
|
+
"--jq",
|
|
245
|
+
".[] | [.name, .ssh_url, .clone_url, .archived, .disabled] | @tsv",
|
|
246
|
+
],
|
|
247
|
+
check=False,
|
|
248
|
+
text=True,
|
|
249
|
+
stdout=subprocess.PIPE,
|
|
250
|
+
stderr=subprocess.PIPE,
|
|
251
|
+
)
|
|
252
|
+
if completed.returncode != 0:
|
|
253
|
+
return []
|
|
254
|
+
|
|
255
|
+
repos: list[GitHubRepo] = []
|
|
256
|
+
for line in completed.stdout.splitlines():
|
|
257
|
+
if not line.strip():
|
|
258
|
+
continue
|
|
259
|
+
fields = line.split("\t")
|
|
260
|
+
if len(fields) != 5:
|
|
261
|
+
continue
|
|
262
|
+
repos.append(
|
|
263
|
+
GitHubRepo(
|
|
264
|
+
name=fields[0],
|
|
265
|
+
ssh_url=fields[1],
|
|
266
|
+
clone_url=fields[2],
|
|
267
|
+
archived=fields[3] == "true",
|
|
268
|
+
disabled=fields[4] == "true",
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
return repos
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def available_clone_destination(clone_dir: Path, repo_name: str) -> Path:
|
|
275
|
+
destination = clone_dir / repo_name
|
|
276
|
+
if not destination.exists() or not any(destination.iterdir()):
|
|
277
|
+
return destination
|
|
278
|
+
|
|
279
|
+
suffix = 1
|
|
280
|
+
while True:
|
|
281
|
+
candidate = clone_dir / f"{repo_name}-{suffix}"
|
|
282
|
+
if not candidate.exists() or not any(candidate.iterdir()):
|
|
283
|
+
return candidate
|
|
284
|
+
suffix += 1
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def print_repository_list(title: str, repos: list[CloneResult]) -> None:
|
|
288
|
+
print(title)
|
|
289
|
+
for result in repos:
|
|
290
|
+
print(f"- {result.repo_name}: {result.destination}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def clone_repository(
|
|
294
|
+
repo: GitHubRepo,
|
|
295
|
+
org: str,
|
|
296
|
+
clone_dir: Path,
|
|
297
|
+
protocol: str,
|
|
298
|
+
dry_run: bool,
|
|
299
|
+
use_gh_clone: bool,
|
|
300
|
+
) -> CloneResult:
|
|
301
|
+
default_destination = clone_dir / repo.name
|
|
302
|
+
destination = available_clone_destination(clone_dir, repo.name)
|
|
303
|
+
source = repo.ssh_url if protocol == "ssh" else repo.clone_url
|
|
304
|
+
|
|
305
|
+
if destination != default_destination:
|
|
306
|
+
print(
|
|
307
|
+
f"warning: {default_destination} is not empty; cloning {repo.name} into {destination}",
|
|
308
|
+
file=sys.stderr,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
print(f"clone: {repo.name} -> {destination}")
|
|
312
|
+
if dry_run:
|
|
313
|
+
return CloneResult(repo.name, destination, "dry-run")
|
|
314
|
+
|
|
315
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
command = (
|
|
317
|
+
["gh", "repo", "clone", f"{org}/{repo.name}", str(destination)]
|
|
318
|
+
if use_gh_clone
|
|
319
|
+
else ["git", "clone", source, str(destination)]
|
|
320
|
+
)
|
|
321
|
+
try:
|
|
322
|
+
subprocess.run(command, check=True)
|
|
323
|
+
return CloneResult(repo.name, destination, "cloned")
|
|
324
|
+
except subprocess.CalledProcessError as error:
|
|
325
|
+
print(f"error: failed to clone {repo.name} ({error})", file=sys.stderr)
|
|
326
|
+
return CloneResult(repo.name, destination, "failed")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def main() -> int:
|
|
330
|
+
args = parse_args()
|
|
331
|
+
root = args.root.expanduser().resolve()
|
|
332
|
+
clone_dir = (args.clone_dir or root / args.org).expanduser().resolve()
|
|
333
|
+
|
|
334
|
+
if not root.exists():
|
|
335
|
+
print(f"error: root directory does not exist: {root}", file=sys.stderr)
|
|
336
|
+
return 2
|
|
337
|
+
|
|
338
|
+
print(f"searching local repositories under {root}")
|
|
339
|
+
local_repos = local_repositories_by_name(root, args.org)
|
|
340
|
+
print(f"found {len(local_repos)} local repositories for {args.org}")
|
|
341
|
+
|
|
342
|
+
if not args.token and not args.no_gh_cli:
|
|
343
|
+
org_repos = fetch_org_repositories_with_gh(args.org)
|
|
344
|
+
if org_repos:
|
|
345
|
+
source = "GitHub CLI"
|
|
346
|
+
else:
|
|
347
|
+
org_repos = fetch_org_repositories(args.github_api, args.org, args.token)
|
|
348
|
+
source = "GitHub API"
|
|
349
|
+
else:
|
|
350
|
+
org_repos = fetch_org_repositories(args.github_api, args.org, args.token)
|
|
351
|
+
source = "GitHub API"
|
|
352
|
+
|
|
353
|
+
active_repos = [
|
|
354
|
+
repo
|
|
355
|
+
for repo in org_repos
|
|
356
|
+
if not repo.disabled and not (args.exclude_archived and repo.archived)
|
|
357
|
+
]
|
|
358
|
+
print(f"found {len(active_repos)} organization repositories from {source}")
|
|
359
|
+
if not args.token and source == "GitHub API":
|
|
360
|
+
print(
|
|
361
|
+
"warning: no GitHub token was provided and GitHub CLI authentication was not usable; "
|
|
362
|
+
"private repositories will not be listed",
|
|
363
|
+
file=sys.stderr,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
missing = [repo for repo in active_repos if repo.name.lower() not in local_repos]
|
|
367
|
+
if not missing:
|
|
368
|
+
print("all organization repositories are already present locally")
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
print(f"{len(missing)} repositories missing locally")
|
|
372
|
+
planned = [
|
|
373
|
+
CloneResult(repo.name, available_clone_destination(clone_dir, repo.name), "pending")
|
|
374
|
+
for repo in missing
|
|
375
|
+
]
|
|
376
|
+
print_repository_list("missing repositories:", planned)
|
|
377
|
+
|
|
378
|
+
results: list[CloneResult] = []
|
|
379
|
+
use_gh_clone = source == "GitHub CLI" and not args.no_gh_cli
|
|
380
|
+
for repo in missing:
|
|
381
|
+
results.append(
|
|
382
|
+
clone_repository(repo, args.org, clone_dir, args.protocol, args.dry_run, use_gh_clone)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
print("summary:")
|
|
386
|
+
for status in ("cloned", "dry-run", "failed"):
|
|
387
|
+
status_results = [result for result in results if result.status == status]
|
|
388
|
+
if status_results:
|
|
389
|
+
print_repository_list(f"{status} repositories ({len(status_results)}):", status_results)
|
|
390
|
+
|
|
391
|
+
failures = sum(1 for result in results if result.status == "failed")
|
|
392
|
+
return 1 if failures else 0
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
if __name__ == "__main__":
|
|
396
|
+
raise SystemExit(main())
|