polygit 0.4.0__tar.gz → 0.6.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.4.0 → polygit-0.6.0}/PKG-INFO +26 -1
- {polygit-0.4.0 → polygit-0.6.0}/README.md +25 -0
- {polygit-0.4.0 → polygit-0.6.0}/pyproject.toml +1 -1
- {polygit-0.4.0 → polygit-0.6.0}/src/polygit/cli.py +16 -5
- {polygit-0.4.0 → polygit-0.6.0}/src/polygit/repo.py +24 -8
- {polygit-0.4.0 → polygit-0.6.0}/tests/test_polygit.py +141 -1
- {polygit-0.4.0 → polygit-0.6.0}/uv.lock +1 -1
- {polygit-0.4.0 → polygit-0.6.0}/.github/workflows/ci.yml +0 -0
- {polygit-0.4.0 → polygit-0.6.0}/.gitignore +0 -0
- {polygit-0.4.0 → polygit-0.6.0}/AGENTS.md +0 -0
- {polygit-0.4.0 → polygit-0.6.0}/CLAUDE.md +0 -0
- {polygit-0.4.0 → polygit-0.6.0}/LICENSE.txt +0 -0
- {polygit-0.4.0 → polygit-0.6.0}/src/polygit/__init__.py +0 -0
- {polygit-0.4.0 → polygit-0.6.0}/src/polygit/config.py +0 -0
- {polygit-0.4.0 → polygit-0.6.0}/src/polygit/repos.py +0 -0
- {polygit-0.4.0 → polygit-0.6.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: polygit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Manage multiple git repos from a single .pgit config file.
|
|
5
5
|
Project-URL: Repository, https://github.com/gschaetz/polygit
|
|
6
6
|
Author-email: Gary Schaetz <gary@schaetzkc.com>
|
|
@@ -49,6 +49,17 @@ pgit push # interactively commit and push dirty repos
|
|
|
49
49
|
pgit repos # discover drift between .pgit and the filesystem
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
`status`, `pull` and `push` accept `--only <platform>` to operate on a single platform
|
|
53
|
+
(e.g. `github`) instead of all of them:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pgit status --only gitlab
|
|
57
|
+
pgit pull --only github --dry-run
|
|
58
|
+
pgit push --only gitlab --all
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Passing an unknown platform prints an error to stderr and exits 1.
|
|
62
|
+
|
|
52
63
|
## Configuration
|
|
53
64
|
|
|
54
65
|
Create a `.pgit` file in the root directory that contains your repos:
|
|
@@ -96,6 +107,20 @@ pgit push --dry-run # list dirty repos that would be committed and pushed
|
|
|
96
107
|
|
|
97
108
|
`pull --dry-run` compares against the last fetched state of each upstream, so run `pgit sync` first for current numbers.
|
|
98
109
|
|
|
110
|
+
## status --json
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pgit status --json # machine-readable list of dirty repos
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Prints a single JSON array on stdout and nothing else:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
[{"platform": "github", "path": "gschaetz/mousehat", "changed_files": 2}]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`changed_files` is the number of entries in `git status --short`. Clean repos are omitted, so the array is empty when there is nothing to commit.
|
|
123
|
+
|
|
99
124
|
## repos — drift detection
|
|
100
125
|
|
|
101
126
|
`pgit repos` compares what's actually on disk against your `.pgit` config and reports:
|
|
@@ -16,6 +16,17 @@ pgit push # interactively commit and push dirty repos
|
|
|
16
16
|
pgit repos # discover drift between .pgit and the filesystem
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
`status`, `pull` and `push` accept `--only <platform>` to operate on a single platform
|
|
20
|
+
(e.g. `github`) instead of all of them:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pgit status --only gitlab
|
|
24
|
+
pgit pull --only github --dry-run
|
|
25
|
+
pgit push --only gitlab --all
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Passing an unknown platform prints an error to stderr and exits 1.
|
|
29
|
+
|
|
19
30
|
## Configuration
|
|
20
31
|
|
|
21
32
|
Create a `.pgit` file in the root directory that contains your repos:
|
|
@@ -63,6 +74,20 @@ pgit push --dry-run # list dirty repos that would be committed and pushed
|
|
|
63
74
|
|
|
64
75
|
`pull --dry-run` compares against the last fetched state of each upstream, so run `pgit sync` first for current numbers.
|
|
65
76
|
|
|
77
|
+
## status --json
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pgit status --json # machine-readable list of dirty repos
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Prints a single JSON array on stdout and nothing else:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
[{"platform": "github", "path": "gschaetz/mousehat", "changed_files": 2}]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`changed_files` is the number of entries in `git status --short`. Clean repos are omitted, so the array is empty when there is nothing to commit.
|
|
90
|
+
|
|
66
91
|
## repos — drift detection
|
|
67
92
|
|
|
68
93
|
`pgit repos` compares what's actually on disk against your `.pgit` config and reports:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
import argparse
|
|
3
|
-
from .config import find_config, load_config
|
|
3
|
+
from .config import find_config, load_config, HOSTS
|
|
4
4
|
from .repo import cmd_sync, cmd_status, cmd_pull, cmd_push
|
|
5
5
|
from .repos import cmd_repos
|
|
6
6
|
|
|
@@ -13,10 +13,14 @@ def main():
|
|
|
13
13
|
sub = parser.add_subparsers(dest="command", metavar="command")
|
|
14
14
|
|
|
15
15
|
sub.add_parser("sync", help="Clone missing repos and fetch all")
|
|
16
|
-
sub.add_parser("status", help="Show repos with uncommitted changes")
|
|
16
|
+
status_p = sub.add_parser("status", help="Show repos with uncommitted changes")
|
|
17
|
+
status_p.add_argument("--json", action="store_true",
|
|
18
|
+
help="Print dirty repos as a JSON list")
|
|
19
|
+
status_p.add_argument("--only", dest="only_platform", help="Limit to a single platform (e.g. github)")
|
|
17
20
|
pull_p = sub.add_parser("pull", help="Pull all repos, auto-stashing local changes")
|
|
18
21
|
pull_p.add_argument("--dry-run", action="store_true",
|
|
19
22
|
help="Show ahead/behind vs. last-fetched upstream; changes nothing")
|
|
23
|
+
pull_p.add_argument("--only", dest="only_platform", help="Limit to a single platform (e.g. github)")
|
|
20
24
|
|
|
21
25
|
push_p = sub.add_parser("push", help="Commit and push dirty repos")
|
|
22
26
|
mode_g = push_p.add_mutually_exclusive_group()
|
|
@@ -29,6 +33,7 @@ def main():
|
|
|
29
33
|
push_p.set_defaults(push_mode="interactive")
|
|
30
34
|
push_p.add_argument("--dry-run", action="store_true",
|
|
31
35
|
help="Show the commit/push plan without committing or pushing")
|
|
36
|
+
push_p.add_argument("--only", dest="only_platform", help="Limit to a single platform (e.g. github)")
|
|
32
37
|
|
|
33
38
|
sub.add_parser("repos", help="Discover drift between .pgit and the filesystem")
|
|
34
39
|
|
|
@@ -47,11 +52,17 @@ def main():
|
|
|
47
52
|
base = config_path.parent
|
|
48
53
|
config = load_config(config_path)
|
|
49
54
|
|
|
55
|
+
only_platform = getattr(args, "only_platform", None)
|
|
56
|
+
if only_platform is not None and only_platform not in HOSTS:
|
|
57
|
+
print(f"Error: unknown platform '{only_platform}'. "
|
|
58
|
+
f"Available: {', '.join(sorted(HOSTS))}", file=sys.stderr)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
50
61
|
commands = {
|
|
51
62
|
"sync": lambda: cmd_sync(config, base),
|
|
52
|
-
"status": lambda: cmd_status(config, base),
|
|
53
|
-
"pull": lambda: cmd_pull(config, base, dry_run=args.dry_run),
|
|
54
|
-
"push": lambda: cmd_push(config, base, mode=args.push_mode, dry_run=args.dry_run),
|
|
63
|
+
"status": lambda: cmd_status(config, base, json_output=args.json, only=only_platform),
|
|
64
|
+
"pull": lambda: cmd_pull(config, base, dry_run=args.dry_run, only=only_platform),
|
|
65
|
+
"push": lambda: cmd_push(config, base, mode=args.push_mode, dry_run=args.dry_run, only=only_platform),
|
|
55
66
|
"repos": lambda: cmd_repos(config, base, config_path),
|
|
56
67
|
}
|
|
57
68
|
commands[args.command]()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import shutil
|
|
2
3
|
import subprocess
|
|
3
4
|
import sys
|
|
@@ -88,9 +89,15 @@ def each_repo(config, base):
|
|
|
88
89
|
yield platform, local_path, remote_path, base / platform / local_path
|
|
89
90
|
|
|
90
91
|
|
|
91
|
-
def each_existing_repo(config, base):
|
|
92
|
-
"""Yield only repos that are already cloned locally.
|
|
92
|
+
def each_existing_repo(config, base, only=None):
|
|
93
|
+
"""Yield only repos that are already cloned locally.
|
|
94
|
+
|
|
95
|
+
If *only* is set, only repos under that platform are yielded. The helper
|
|
96
|
+
is used by :func:`cmd_status`, :func:`cmd_pull` and :func:`cmd_push`.
|
|
97
|
+
"""
|
|
93
98
|
for platform, local_path, remote_path, d in each_repo(config, base):
|
|
99
|
+
if only is not None and platform != only:
|
|
100
|
+
continue
|
|
94
101
|
if (d / ".git").exists():
|
|
95
102
|
yield platform, local_path, remote_path, d
|
|
96
103
|
|
|
@@ -194,7 +201,7 @@ def cmd_sync(config, base):
|
|
|
194
201
|
f.result()
|
|
195
202
|
|
|
196
203
|
|
|
197
|
-
def cmd_status(config, base):
|
|
204
|
+
def cmd_status(config, base, json_output=False, only=None):
|
|
198
205
|
results = []
|
|
199
206
|
|
|
200
207
|
def _check(platform, local_path, d):
|
|
@@ -204,12 +211,21 @@ def cmd_status(config, base):
|
|
|
204
211
|
)
|
|
205
212
|
return platform, local_path, result.stdout.strip()
|
|
206
213
|
|
|
207
|
-
repos = list(each_existing_repo(config, base))
|
|
214
|
+
repos = list(each_existing_repo(config, base, only=only))
|
|
208
215
|
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
|
209
216
|
futures = {pool.submit(_check, p, lp, d): (p, lp) for p, lp, _, d in repos}
|
|
210
217
|
for f in as_completed(futures):
|
|
211
218
|
results.append(f.result())
|
|
212
219
|
|
|
220
|
+
if json_output:
|
|
221
|
+
entries = [
|
|
222
|
+
{"platform": platform, "path": local_path, "changed_files": len(output.splitlines())}
|
|
223
|
+
for platform, local_path, output in sorted(results)
|
|
224
|
+
if output
|
|
225
|
+
]
|
|
226
|
+
print(json.dumps(entries))
|
|
227
|
+
return
|
|
228
|
+
|
|
213
229
|
found_dirty = False
|
|
214
230
|
for platform, local_path, output in sorted(results):
|
|
215
231
|
if output:
|
|
@@ -221,18 +237,18 @@ def cmd_status(config, base):
|
|
|
221
237
|
print(f"{OK} All repos are clean.")
|
|
222
238
|
|
|
223
239
|
|
|
224
|
-
def cmd_pull(config, base, dry_run=False):
|
|
225
|
-
repos = list(each_existing_repo(config, base))
|
|
240
|
+
def cmd_pull(config, base, dry_run=False, only=None):
|
|
241
|
+
repos = list(each_existing_repo(config, base, only=only))
|
|
226
242
|
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
|
227
243
|
futures = [pool.submit(_pull_one, p, lp, d, dry_run) for p, lp, _, d in repos]
|
|
228
244
|
for f in as_completed(futures):
|
|
229
245
|
f.result()
|
|
230
246
|
|
|
231
247
|
|
|
232
|
-
def cmd_push(config, base, mode="interactive", dry_run=False):
|
|
248
|
+
def cmd_push(config, base, mode="interactive", dry_run=False, only=None):
|
|
233
249
|
dirty = [
|
|
234
250
|
(platform, local_path, d)
|
|
235
|
-
for platform, local_path, _, d in each_existing_repo(config, base)
|
|
251
|
+
for platform, local_path, _, d in each_existing_repo(config, base, only=only)
|
|
236
252
|
if is_dirty(d)
|
|
237
253
|
]
|
|
238
254
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import subprocess
|
|
2
3
|
import tempfile
|
|
3
4
|
import pytest
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from polygit.config import find_config, load_config, write_config
|
|
6
7
|
from polygit.repos import parse_remote_url, del_nested, _add_to_config
|
|
7
|
-
from polygit.repo import walk, _pull_one, cmd_push
|
|
8
|
+
from polygit.repo import walk, _pull_one, cmd_push, cmd_status, cmd_pull
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
# ── parse_remote_url ──────────────────────────────────────────────────────────
|
|
@@ -195,3 +196,142 @@ def test_push_dry_run_lists_dirty_without_committing(capsys, tmp_path):
|
|
|
195
196
|
log = subprocess.run(["git", "-C", str(d), "log", "--oneline"],
|
|
196
197
|
capture_output=True, text=True).stdout
|
|
197
198
|
assert len(log.splitlines()) == 1
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ── status --json ────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
def test_status_json_lists_dirty_repos(capsys, tmp_path):
|
|
204
|
+
d = _make_clone(tmp_path)
|
|
205
|
+
(d / "init").write_text("edited")
|
|
206
|
+
(d / "a.txt").write_text("a")
|
|
207
|
+
(d / "b.txt").write_text("b")
|
|
208
|
+
|
|
209
|
+
cmd_status({"github": {"r": None}}, tmp_path, json_output=True)
|
|
210
|
+
|
|
211
|
+
payload = json.loads(capsys.readouterr().out)
|
|
212
|
+
assert payload == [{"platform": "github", "path": "r", "changed_files": 3}]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_status_json_empty_when_clean(capsys, tmp_path):
|
|
216
|
+
_make_clone(tmp_path)
|
|
217
|
+
cmd_status({"github": {"r": None}}, tmp_path, json_output=True)
|
|
218
|
+
assert json.loads(capsys.readouterr().out) == []
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_status_json_omits_clean_repos(capsys, tmp_path):
|
|
222
|
+
_make_clone(tmp_path)
|
|
223
|
+
dirty = tmp_path / "github" / "other"
|
|
224
|
+
subprocess.run(["git", "clone", "-q", str(tmp_path / "origin.git"), str(dirty)],
|
|
225
|
+
check=True, capture_output=True)
|
|
226
|
+
(dirty / "x.txt").write_text("x")
|
|
227
|
+
|
|
228
|
+
cmd_status({"github": {"r": None, "other": None}}, tmp_path, json_output=True)
|
|
229
|
+
|
|
230
|
+
payload = json.loads(capsys.readouterr().out)
|
|
231
|
+
assert payload == [{"platform": "github", "path": "other", "changed_files": 1}]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_status_default_output_unchanged(capsys, tmp_path):
|
|
235
|
+
d = _make_clone(tmp_path)
|
|
236
|
+
(d / "dirty.txt").write_text("x")
|
|
237
|
+
|
|
238
|
+
cmd_status({"github": {"r": None}}, tmp_path)
|
|
239
|
+
out = capsys.readouterr().out
|
|
240
|
+
|
|
241
|
+
assert "github/r" in out
|
|
242
|
+
assert "dirty.txt" in out
|
|
243
|
+
assert out.strip()[0] != "["
|
|
244
|
+
with pytest.raises(ValueError):
|
|
245
|
+
json.loads(out)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_status_default_clean_message(capsys, tmp_path):
|
|
249
|
+
_make_clone(tmp_path)
|
|
250
|
+
cmd_status({"github": {"r": None}}, tmp_path)
|
|
251
|
+
assert "All repos are clean." in capsys.readouterr().out
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── --only platform filter ───────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
def _gitlab_clone(tmp_path, name="gl", commit=True):
|
|
257
|
+
d = tmp_path / "gitlab" / name
|
|
258
|
+
d.parent.mkdir()
|
|
259
|
+
subprocess.run(["git", "clone", "-q", str(tmp_path / "origin.git"), str(d)],
|
|
260
|
+
check=True, capture_output=True)
|
|
261
|
+
if commit:
|
|
262
|
+
_commit(d, "seed")
|
|
263
|
+
_git(d, "push", "-q", "origin", "HEAD:main")
|
|
264
|
+
return d
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_status_only_limits_to_platform(capsys, tmp_path):
|
|
268
|
+
github = _make_clone(tmp_path)
|
|
269
|
+
(github / "x.txt").write_text("x")
|
|
270
|
+
_gitlab_clone(tmp_path)
|
|
271
|
+
(tmp_path / "gitlab" / "gl" / "y.txt").write_text("y")
|
|
272
|
+
|
|
273
|
+
cmd_status({"github": {"r": None}, "gitlab": {"gl": None}}, tmp_path, only="github")
|
|
274
|
+
|
|
275
|
+
out = capsys.readouterr().out
|
|
276
|
+
assert "github/r" in out
|
|
277
|
+
assert "gitlab/gl" not in out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_status_only_json(capsys, tmp_path):
|
|
281
|
+
github = _make_clone(tmp_path)
|
|
282
|
+
(github / "x.txt").write_text("x")
|
|
283
|
+
_gitlab_clone(tmp_path)
|
|
284
|
+
(tmp_path / "gitlab" / "gl" / "y.txt").write_text("y")
|
|
285
|
+
|
|
286
|
+
cmd_status({"github": {"r": None}, "gitlab": {"gl": None}}, tmp_path,
|
|
287
|
+
json_output=True, only="github")
|
|
288
|
+
|
|
289
|
+
assert json.loads(capsys.readouterr().out) == [
|
|
290
|
+
{"platform": "github", "path": "r", "changed_files": 1}
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_pull_only_limits_to_platform(capsys, tmp_path):
|
|
295
|
+
_make_clone(tmp_path, ahead=2)
|
|
296
|
+
_gitlab_clone(tmp_path)
|
|
297
|
+
_commit(tmp_path / "gitlab" / "gl", "ahead")
|
|
298
|
+
_git(tmp_path / "gitlab" / "gl", "push", "-q", "origin", "HEAD:main")
|
|
299
|
+
|
|
300
|
+
cmd_pull({"github": {"r": None}, "gitlab": {"gl": None}}, tmp_path,
|
|
301
|
+
dry_run=True, only="github")
|
|
302
|
+
|
|
303
|
+
out = capsys.readouterr().out
|
|
304
|
+
assert "github/r" in out
|
|
305
|
+
assert "gitlab/gl" not in out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_push_only_limits_to_platform(capsys, tmp_path):
|
|
309
|
+
github = _make_clone(tmp_path)
|
|
310
|
+
(github / "g.txt").write_text("x")
|
|
311
|
+
_gitlab_clone(tmp_path)
|
|
312
|
+
(tmp_path / "gitlab" / "gl" / "l.txt").write_text("x")
|
|
313
|
+
|
|
314
|
+
cmd_push({"github": {"r": None}, "gitlab": {"gl": None}}, tmp_path,
|
|
315
|
+
dry_run=True, only="github")
|
|
316
|
+
|
|
317
|
+
out = capsys.readouterr().out
|
|
318
|
+
assert "github/r" in out
|
|
319
|
+
assert "gitlab/gl" not in out
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_status_unknown_platform_errors(capsys, tmp_path, monkeypatch):
|
|
323
|
+
import sys
|
|
324
|
+
from polygit.cli import main
|
|
325
|
+
|
|
326
|
+
_make_clone(tmp_path)
|
|
327
|
+
(tmp_path / ".pgit").write_text("github:\n r:\n")
|
|
328
|
+
monkeypatch.chdir(tmp_path)
|
|
329
|
+
monkeypatch.setattr(sys, "argv", ["pgit", "status", "--only", "bitbucket"])
|
|
330
|
+
|
|
331
|
+
with pytest.raises(SystemExit) as exc:
|
|
332
|
+
main()
|
|
333
|
+
|
|
334
|
+
assert exc.value.code == 1
|
|
335
|
+
err = capsys.readouterr().err
|
|
336
|
+
assert "bitbucket" in err
|
|
337
|
+
assert "github" in err
|
|
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
|