polygit 0.1.0__tar.gz → 0.3.0__tar.gz
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.
- {polygit-0.1.0 → polygit-0.3.0}/PKG-INFO +1 -1
- {polygit-0.1.0 → polygit-0.3.0}/pyproject.toml +1 -1
- polygit-0.3.0/src/polygit/repo.py +230 -0
- polygit-0.1.0/src/polygit/repo.py +0 -151
- {polygit-0.1.0 → polygit-0.3.0}/.gitignore +0 -0
- {polygit-0.1.0 → polygit-0.3.0}/.gitlab-ci.yml +0 -0
- {polygit-0.1.0 → polygit-0.3.0}/LICENSE.txt +0 -0
- {polygit-0.1.0 → polygit-0.3.0}/README.md +0 -0
- {polygit-0.1.0 → polygit-0.3.0}/src/polygit/__init__.py +0 -0
- {polygit-0.1.0 → polygit-0.3.0}/src/polygit/cli.py +0 -0
- {polygit-0.1.0 → polygit-0.3.0}/src/polygit/config.py +0 -0
- {polygit-0.1.0 → polygit-0.3.0}/src/polygit/repos.py +0 -0
- {polygit-0.1.0 → polygit-0.3.0}/tests/__init__.py +0 -0
- {polygit-0.1.0 → polygit-0.3.0}/tests/test_polygit.py +0 -0
- {polygit-0.1.0 → polygit-0.3.0}/uv.lock +0 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from .config import HOSTS
|
|
8
|
+
|
|
9
|
+
_print_lock = threading.Lock()
|
|
10
|
+
MAX_WORKERS = 8
|
|
11
|
+
|
|
12
|
+
_USE_COLOR = sys.stdout.isatty()
|
|
13
|
+
|
|
14
|
+
_G = "\033[32m" if _USE_COLOR else "" # green
|
|
15
|
+
_R = "\033[31m" if _USE_COLOR else "" # red
|
|
16
|
+
_C = "\033[36m" if _USE_COLOR else "" # cyan
|
|
17
|
+
_Y = "\033[33m" if _USE_COLOR else "" # yellow
|
|
18
|
+
_DIM = "\033[2m" if _USE_COLOR else "" # dim
|
|
19
|
+
_RST = "\033[0m" if _USE_COLOR else "" # reset
|
|
20
|
+
|
|
21
|
+
OK = f"{_G}✓{_RST}"
|
|
22
|
+
UPD = f"{_C}↓{_RST}"
|
|
23
|
+
ERR = f"{_R}✗{_RST}"
|
|
24
|
+
NEW = f"{_C}+{_RST}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _emit(lines):
|
|
28
|
+
"""Print a block of lines atomically."""
|
|
29
|
+
with _print_lock:
|
|
30
|
+
for line in lines:
|
|
31
|
+
print(line)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def walk(node, prefix):
|
|
35
|
+
"""Yield (local_path, remote_path) for every repo in the tree."""
|
|
36
|
+
if node is None:
|
|
37
|
+
yield prefix, prefix
|
|
38
|
+
elif isinstance(node, dict) and set(node.keys()) == {"remote"}:
|
|
39
|
+
yield prefix, node["remote"]
|
|
40
|
+
elif isinstance(node, dict):
|
|
41
|
+
for key, value in node.items():
|
|
42
|
+
yield from walk(value, f"{prefix}/{key}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def each_repo(config, base):
|
|
46
|
+
"""Yield (platform, local_path, remote_path, dir) for all configured repos."""
|
|
47
|
+
for platform, namespaces in config.items():
|
|
48
|
+
if platform not in HOSTS:
|
|
49
|
+
continue
|
|
50
|
+
for ns_key, ns_value in namespaces.items():
|
|
51
|
+
for local_path, remote_path in walk(ns_value, ns_key):
|
|
52
|
+
yield platform, local_path, remote_path, base / platform / local_path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def each_existing_repo(config, base):
|
|
56
|
+
"""Yield only repos that are already cloned locally."""
|
|
57
|
+
for platform, local_path, remote_path, d in each_repo(config, base):
|
|
58
|
+
if (d / ".git").exists():
|
|
59
|
+
yield platform, local_path, remote_path, d
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_dirty(d):
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["git", "-C", str(d), "status", "--porcelain"],
|
|
65
|
+
capture_output=True, text=True
|
|
66
|
+
)
|
|
67
|
+
return bool(result.stdout.strip())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _commit_and_push(d, label, msg):
|
|
71
|
+
lines = [f" Committing {label}..."]
|
|
72
|
+
subprocess.run(["git", "-C", str(d), "add", "-A"], capture_output=True)
|
|
73
|
+
subprocess.run(["git", "-C", str(d), "commit", "-m", msg], capture_output=True)
|
|
74
|
+
result = subprocess.run(["git", "-C", str(d), "push"], capture_output=True, text=True)
|
|
75
|
+
if result.returncode != 0:
|
|
76
|
+
lines.append(f"{ERR} {label}: push failed — {result.stderr.strip().splitlines()[0]}")
|
|
77
|
+
else:
|
|
78
|
+
lines.append(f"{UPD} {label}: pushed")
|
|
79
|
+
_emit(lines)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _sync_one(platform, local_path, remote_path, d):
|
|
83
|
+
url = f"{HOSTS[platform]}:{remote_path}.git"
|
|
84
|
+
label = f"{platform}/{local_path}"
|
|
85
|
+
if (d / ".git").exists():
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
["git", "-C", str(d), "fetch", "--all", "-q"],
|
|
88
|
+
capture_output=True, text=True
|
|
89
|
+
)
|
|
90
|
+
if result.returncode != 0:
|
|
91
|
+
lines = [f"{ERR} {label}: {result.stderr.strip().splitlines()[0]}"]
|
|
92
|
+
else:
|
|
93
|
+
lines = [f"{OK} {_DIM}{label}{_RST}"]
|
|
94
|
+
else:
|
|
95
|
+
d.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
["git", "clone", url, str(d)],
|
|
98
|
+
capture_output=True, text=True
|
|
99
|
+
)
|
|
100
|
+
if result.returncode != 0:
|
|
101
|
+
lines = [f"{ERR} {label}: {result.stderr.strip().splitlines()[0]}"]
|
|
102
|
+
else:
|
|
103
|
+
lines = [f"{NEW} {label}: cloned"]
|
|
104
|
+
_emit(lines)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _pull_one(platform, local_path, d):
|
|
108
|
+
label = f"{platform}/{local_path}"
|
|
109
|
+
lines = []
|
|
110
|
+
stashed = False
|
|
111
|
+
if is_dirty(d):
|
|
112
|
+
subprocess.run(["git", "-C", str(d), "stash"], capture_output=True)
|
|
113
|
+
stashed = True
|
|
114
|
+
result = subprocess.run(
|
|
115
|
+
["git", "-C", str(d), "pull", "--rebase"],
|
|
116
|
+
capture_output=True, text=True
|
|
117
|
+
)
|
|
118
|
+
if result.returncode != 0:
|
|
119
|
+
err = result.stderr.strip()
|
|
120
|
+
if "no tracking information" in err or "no upstream" in err.lower():
|
|
121
|
+
lines.append(f"{ERR} {label}: no upstream branch set")
|
|
122
|
+
else:
|
|
123
|
+
lines.append(f"{ERR} {label}: {err.splitlines()[0]}")
|
|
124
|
+
elif result.stdout.strip() and result.stdout.strip() != "Already up to date.":
|
|
125
|
+
lines.append(f"{UPD} {label}:")
|
|
126
|
+
for ln in result.stdout.strip().splitlines():
|
|
127
|
+
lines.append(f" {ln}")
|
|
128
|
+
else:
|
|
129
|
+
lines.append(f"{OK} {_DIM}{label}{_RST}")
|
|
130
|
+
if stashed:
|
|
131
|
+
pop = subprocess.run(
|
|
132
|
+
["git", "-C", str(d), "stash", "pop"],
|
|
133
|
+
capture_output=True, text=True
|
|
134
|
+
)
|
|
135
|
+
if pop.returncode != 0:
|
|
136
|
+
lines.append(f" {_Y}warning:{_RST} stash pop had conflicts in {label}")
|
|
137
|
+
else:
|
|
138
|
+
lines.append(f" stash restored")
|
|
139
|
+
_emit(lines)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_sync(config, base):
|
|
143
|
+
repos = list(each_repo(config, base))
|
|
144
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
|
145
|
+
futures = [pool.submit(_sync_one, p, lp, rp, d) for p, lp, rp, d in repos]
|
|
146
|
+
for f in as_completed(futures):
|
|
147
|
+
f.result()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_status(config, base):
|
|
151
|
+
results = []
|
|
152
|
+
|
|
153
|
+
def _check(platform, local_path, d):
|
|
154
|
+
result = subprocess.run(
|
|
155
|
+
["git", "-C", str(d), "status", "--short"],
|
|
156
|
+
capture_output=True, text=True
|
|
157
|
+
)
|
|
158
|
+
return platform, local_path, result.stdout.strip()
|
|
159
|
+
|
|
160
|
+
repos = list(each_existing_repo(config, base))
|
|
161
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
|
162
|
+
futures = {pool.submit(_check, p, lp, d): (p, lp) for p, lp, _, d in repos}
|
|
163
|
+
for f in as_completed(futures):
|
|
164
|
+
results.append(f.result())
|
|
165
|
+
|
|
166
|
+
found_dirty = False
|
|
167
|
+
for platform, local_path, output in sorted(results):
|
|
168
|
+
if output:
|
|
169
|
+
found_dirty = True
|
|
170
|
+
print(f"\n{_Y}~{_RST} {platform}/{local_path}:")
|
|
171
|
+
for line in output.splitlines():
|
|
172
|
+
print(f" {line}")
|
|
173
|
+
if not found_dirty:
|
|
174
|
+
print(f"{OK} All repos are clean.")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_pull(config, base):
|
|
178
|
+
repos = list(each_existing_repo(config, base))
|
|
179
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
|
180
|
+
futures = [pool.submit(_pull_one, p, lp, d) for p, lp, _, d in repos]
|
|
181
|
+
for f in as_completed(futures):
|
|
182
|
+
f.result()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_push(config, base, mode="interactive"):
|
|
186
|
+
dirty = [
|
|
187
|
+
(platform, local_path, d)
|
|
188
|
+
for platform, local_path, _, d in each_existing_repo(config, base)
|
|
189
|
+
if is_dirty(d)
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
if not dirty:
|
|
193
|
+
print("No dirty repos to commit.")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
if mode == "wip":
|
|
197
|
+
msg = f"WIP {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
|
198
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
|
199
|
+
futures = [pool.submit(_commit_and_push, d, f"{p}/{lp}", msg) for p, lp, d in dirty]
|
|
200
|
+
for f in as_completed(futures):
|
|
201
|
+
f.result()
|
|
202
|
+
|
|
203
|
+
elif mode == "all":
|
|
204
|
+
print("Dirty repos:")
|
|
205
|
+
for platform, local_path, _ in dirty:
|
|
206
|
+
print(f" {platform}/{local_path}")
|
|
207
|
+
msg = input("\nCommit message for all repos (blank to abort): ").strip()
|
|
208
|
+
if not msg:
|
|
209
|
+
print("Aborted.")
|
|
210
|
+
return
|
|
211
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
|
212
|
+
futures = [pool.submit(_commit_and_push, d, f"{p}/{lp}", msg) for p, lp, d in dirty]
|
|
213
|
+
for f in as_completed(futures):
|
|
214
|
+
f.result()
|
|
215
|
+
|
|
216
|
+
else: # interactive — must stay sequential for user input
|
|
217
|
+
for platform, local_path, d in dirty:
|
|
218
|
+
label = f"{platform}/{local_path}"
|
|
219
|
+
status = subprocess.run(
|
|
220
|
+
["git", "-C", str(d), "status", "--short"],
|
|
221
|
+
capture_output=True, text=True
|
|
222
|
+
).stdout.strip()
|
|
223
|
+
print(f"\n{label}:")
|
|
224
|
+
for line in status.splitlines():
|
|
225
|
+
print(f" {line}")
|
|
226
|
+
msg = input(" Commit message (blank to skip): ").strip()
|
|
227
|
+
if not msg:
|
|
228
|
+
print(" Skipped.")
|
|
229
|
+
continue
|
|
230
|
+
_commit_and_push(d, label, msg)
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import subprocess
|
|
2
|
-
from datetime import datetime
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from .config import HOSTS
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def walk(node, prefix):
|
|
8
|
-
"""Yield (local_path, remote_path) for every repo in the tree."""
|
|
9
|
-
if node is None:
|
|
10
|
-
yield prefix, prefix
|
|
11
|
-
elif isinstance(node, dict) and set(node.keys()) == {"remote"}:
|
|
12
|
-
yield prefix, node["remote"]
|
|
13
|
-
elif isinstance(node, dict):
|
|
14
|
-
for key, value in node.items():
|
|
15
|
-
yield from walk(value, f"{prefix}/{key}")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def each_repo(config, base):
|
|
19
|
-
"""Yield (platform, local_path, remote_path, dir) for all configured repos."""
|
|
20
|
-
for platform, namespaces in config.items():
|
|
21
|
-
if platform not in HOSTS:
|
|
22
|
-
continue
|
|
23
|
-
for ns_key, ns_value in namespaces.items():
|
|
24
|
-
for local_path, remote_path in walk(ns_value, ns_key):
|
|
25
|
-
yield platform, local_path, remote_path, base / platform / local_path
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def each_existing_repo(config, base):
|
|
29
|
-
"""Yield only repos that are already cloned locally."""
|
|
30
|
-
for platform, local_path, remote_path, d in each_repo(config, base):
|
|
31
|
-
if (d / ".git").exists():
|
|
32
|
-
yield platform, local_path, remote_path, d
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def is_dirty(d):
|
|
36
|
-
result = subprocess.run(
|
|
37
|
-
["git", "-C", str(d), "status", "--porcelain"],
|
|
38
|
-
capture_output=True, text=True
|
|
39
|
-
)
|
|
40
|
-
return bool(result.stdout.strip())
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _commit_and_push(d, label, msg):
|
|
44
|
-
print(f"Committing {label}...")
|
|
45
|
-
subprocess.run(["git", "-C", str(d), "add", "-A"])
|
|
46
|
-
subprocess.run(["git", "-C", str(d), "commit", "-m", msg])
|
|
47
|
-
result = subprocess.run(["git", "-C", str(d), "push"], capture_output=True, text=True)
|
|
48
|
-
if result.returncode != 0:
|
|
49
|
-
print(f" Push failed: {result.stderr.strip()}")
|
|
50
|
-
else:
|
|
51
|
-
print(f" Pushed.")
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def cmd_sync(config, base):
|
|
55
|
-
for platform, local_path, remote_path, d in each_repo(config, base):
|
|
56
|
-
url = f"{HOSTS[platform]}:{remote_path}.git"
|
|
57
|
-
if (d / ".git").exists():
|
|
58
|
-
print(f"Fetching {platform}/{local_path}...")
|
|
59
|
-
subprocess.run(["git", "-C", str(d), "fetch", "--all", "-q"])
|
|
60
|
-
else:
|
|
61
|
-
print(f"Cloning {platform}/{local_path}...")
|
|
62
|
-
d.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
-
subprocess.run(["git", "clone", url, str(d)])
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def cmd_status(config, base):
|
|
67
|
-
found_dirty = False
|
|
68
|
-
for platform, local_path, _, d in each_existing_repo(config, base):
|
|
69
|
-
result = subprocess.run(
|
|
70
|
-
["git", "-C", str(d), "status", "--short"],
|
|
71
|
-
capture_output=True, text=True
|
|
72
|
-
)
|
|
73
|
-
if result.stdout.strip():
|
|
74
|
-
found_dirty = True
|
|
75
|
-
print(f"\n{platform}/{local_path}:")
|
|
76
|
-
for line in result.stdout.strip().splitlines():
|
|
77
|
-
print(f" {line}")
|
|
78
|
-
if not found_dirty:
|
|
79
|
-
print("All repos are clean.")
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def cmd_pull(config, base):
|
|
83
|
-
for platform, local_path, _, d in each_existing_repo(config, base):
|
|
84
|
-
label = f"{platform}/{local_path}"
|
|
85
|
-
stashed = False
|
|
86
|
-
if is_dirty(d):
|
|
87
|
-
print(f"Stashing {label}...")
|
|
88
|
-
subprocess.run(["git", "-C", str(d), "stash"], capture_output=True)
|
|
89
|
-
stashed = True
|
|
90
|
-
print(f"Pulling {label}...")
|
|
91
|
-
result = subprocess.run(
|
|
92
|
-
["git", "-C", str(d), "pull", "--rebase"],
|
|
93
|
-
capture_output=True, text=True
|
|
94
|
-
)
|
|
95
|
-
if result.returncode != 0:
|
|
96
|
-
print(f" Error: {result.stderr.strip()}")
|
|
97
|
-
elif result.stdout.strip() and result.stdout.strip() != "Already up to date.":
|
|
98
|
-
print(f" {result.stdout.strip()}")
|
|
99
|
-
if stashed:
|
|
100
|
-
pop = subprocess.run(
|
|
101
|
-
["git", "-C", str(d), "stash", "pop"],
|
|
102
|
-
capture_output=True, text=True
|
|
103
|
-
)
|
|
104
|
-
if pop.returncode != 0:
|
|
105
|
-
print(f" Warning: stash pop had conflicts in {label}")
|
|
106
|
-
else:
|
|
107
|
-
print(f" Stash restored.")
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def cmd_push(config, base, mode="interactive"):
|
|
111
|
-
dirty = [
|
|
112
|
-
(platform, local_path, d)
|
|
113
|
-
for platform, local_path, _, d in each_existing_repo(config, base)
|
|
114
|
-
if is_dirty(d)
|
|
115
|
-
]
|
|
116
|
-
|
|
117
|
-
if not dirty:
|
|
118
|
-
print("No dirty repos to commit.")
|
|
119
|
-
return
|
|
120
|
-
|
|
121
|
-
if mode == "wip":
|
|
122
|
-
msg = f"WIP {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
|
123
|
-
for platform, local_path, d in dirty:
|
|
124
|
-
_commit_and_push(d, f"{platform}/{local_path}", msg)
|
|
125
|
-
|
|
126
|
-
elif mode == "all":
|
|
127
|
-
print("Dirty repos:")
|
|
128
|
-
for platform, local_path, _ in dirty:
|
|
129
|
-
print(f" {platform}/{local_path}")
|
|
130
|
-
msg = input("\nCommit message for all repos (blank to abort): ").strip()
|
|
131
|
-
if not msg:
|
|
132
|
-
print("Aborted.")
|
|
133
|
-
return
|
|
134
|
-
for platform, local_path, d in dirty:
|
|
135
|
-
_commit_and_push(d, f"{platform}/{local_path}", msg)
|
|
136
|
-
|
|
137
|
-
else: # interactive
|
|
138
|
-
for platform, local_path, d in dirty:
|
|
139
|
-
label = f"{platform}/{local_path}"
|
|
140
|
-
status = subprocess.run(
|
|
141
|
-
["git", "-C", str(d), "status", "--short"],
|
|
142
|
-
capture_output=True, text=True
|
|
143
|
-
).stdout.strip()
|
|
144
|
-
print(f"\n{label}:")
|
|
145
|
-
for line in status.splitlines():
|
|
146
|
-
print(f" {line}")
|
|
147
|
-
msg = input(" Commit message (blank to skip): ").strip()
|
|
148
|
-
if not msg:
|
|
149
|
-
print(" Skipped.")
|
|
150
|
-
continue
|
|
151
|
-
_commit_and_push(d, label, msg)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|