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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: polygit
3
- Version: 0.4.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:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "polygit"
7
- version = "0.4.0"
7
+ version = "0.6.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" }
@@ -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
@@ -117,7 +117,7 @@ wheels = [
117
117
 
118
118
  [[package]]
119
119
  name = "polygit"
120
- version = "0.4.0"
120
+ version = "0.6.0"
121
121
  source = { editable = "." }
122
122
  dependencies = [
123
123
  { name = "pyyaml" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes