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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polygit
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Manage multiple git repos from a single .pgit config file.
5
5
  Project-URL: Repository, https://gitlab.com/gary.schaetz/public/polygit
6
6
  Author-email: Gary Schaetz <gary@schaetzkc.com>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "polygit"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "Manage multiple git repos from a single .pgit config file."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE.txt" }
@@ -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