@codebend3r/che 1.0.0

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/bin/che.py +869 -0
  4. package/bin/commands.py +872 -0
  5. package/bin/drives/eject-all-drives.py +203 -0
  6. package/bin/drives/mount-all-drives.py +140 -0
  7. package/bin/drives/ping-nas.py +242 -0
  8. package/bin/files/compress-folders.py +188 -0
  9. package/bin/files/delete-by-ext.py +108 -0
  10. package/bin/files/delete-empty-folders.py +136 -0
  11. package/bin/files/delete-smb-files.py +105 -0
  12. package/bin/files/files-under-size.py +132 -0
  13. package/bin/files/find-largest-files.py +102 -0
  14. package/bin/files/make-alpha-dir.py +63 -0
  15. package/bin/git/all-actions.py +545 -0
  16. package/bin/git/checkout-my-branches.py +147 -0
  17. package/bin/git/clean-stale-branches.py +161 -0
  18. package/bin/git/prune-worktrees.py +143 -0
  19. package/bin/git/sync-all-branches.py +368 -0
  20. package/bin/git/update-local-branches.py +195 -0
  21. package/bin/install.py +1515 -0
  22. package/bin/shellgen.py +782 -0
  23. package/bin/system/btop-launch.py +114 -0
  24. package/bin/system/update-brew.py +146 -0
  25. package/bin/tui.py +648 -0
  26. package/bin/utils.py +836 -0
  27. package/bin/video/delete-duplicate-videos.py +313 -0
  28. package/bin/video/detect-green-magenta-videos.py +268 -0
  29. package/bin/video/find-movie-by-year.py +84 -0
  30. package/bin/video/find-video-mkv-issues.py +242 -0
  31. package/bin/video/fix-codecs.py +209 -0
  32. package/bin/video/largest-tv-shows.py +175 -0
  33. package/bin/video/remove-metadata.py +126 -0
  34. package/bin/video/rename-video-file.py +329 -0
  35. package/bin/video/scan-videos-audio-language.py +110 -0
  36. package/bin/video/show-codecs.py +111 -0
  37. package/bin/video/validate-video-files.py +114 -0
  38. package/bin/video/video-list.py +120 -0
  39. package/package.json +54 -0
  40. package/shell/che.bash +382 -0
  41. package/shell/che.fish +282 -0
  42. package/shell/che.zsh +389 -0
  43. package/shell/completions/_che +146 -0
  44. package/shell/completions/che.bash +107 -0
  45. package/shell/completions/che.fish +371 -0
@@ -0,0 +1,545 @@
1
+ #!/usr/bin/env python3
2
+ """๐ŸŽฌ Show the latest GitHub Actions run for every open PR you authored.
3
+
4
+ One row per PR, so a repo with five open PRs gets five rows. The repo name is a
5
+ terminal hyperlink to that run.
6
+
7
+ When a PR's head commit has several workflow runs, the row shows the one worth
8
+ acting on: anything still in flight, else a failure, else the most recent run.
9
+
10
+ Usage:
11
+ all-actions.py [--owner=NAME[,NAME...]] [--author=NAME] [--pr-limit=N]
12
+ [--watch[=SECONDS]] [--interval=SECONDS]
13
+
14
+ Options:
15
+ --owner=NAME Owner(s) to scan, comma-separated (default: your gh login)
16
+ --author=NAME PR author to match (default: your gh login)
17
+ --pr-limit=N Open PRs to inspect, max 100 (default: 100)
18
+ --interval=SECONDS Refresh interval used by --watch (default: 15)
19
+ --watch[=SECONDS] Refresh every interval until interrupted
20
+ --help Show help
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ import sys
28
+ import time
29
+ import unicodedata
30
+ from datetime import UTC, datetime
31
+ from pathlib import Path
32
+
33
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
34
+
35
+ from utils import (
36
+ CYAN,
37
+ GREEN,
38
+ MAGENTA,
39
+ NC,
40
+ RED,
41
+ YELLOW,
42
+ build_parser,
43
+ color_enabled,
44
+ info,
45
+ note,
46
+ require_binary,
47
+ run,
48
+ run_cli,
49
+ run_output,
50
+ success,
51
+ warning,
52
+ )
53
+
54
+ __version__ = "2.0.0"
55
+
56
+ STATUS_COLORS = {
57
+ "in_progress": CYAN,
58
+ "queued": YELLOW,
59
+ "waiting": YELLOW,
60
+ "pending": YELLOW,
61
+ "requested": YELLOW,
62
+ "success": GREEN,
63
+ "failure": RED,
64
+ "timed_out": RED,
65
+ "startup_failure": RED,
66
+ "action_required": RED,
67
+ }
68
+
69
+ STATUS_ICONS = {
70
+ "in_progress": "๐Ÿ”„",
71
+ "queued": "โณ",
72
+ "waiting": "โณ",
73
+ "pending": "โณ",
74
+ "requested": "โณ",
75
+ "success": "โœ…",
76
+ "failure": "โŒ",
77
+ "timed_out": "โŒ",
78
+ "startup_failure": "โŒ",
79
+ "cancelled": "๐Ÿšซ",
80
+ "skipped": "โฉ",
81
+ "none": "โž–", # noqa: RUF001
82
+ }
83
+
84
+ LIVE_STATUSES = frozenset({"in_progress", "queued", "waiting", "pending", "requested"})
85
+ BAD_CONCLUSIONS = frozenset({"FAILURE", "TIMED_OUT", "STARTUP_FAILURE"})
86
+
87
+ GRAPHQL_QUERY = """
88
+ query($q: String!, $first: Int!) {
89
+ search(query: $q, type: ISSUE, first: $first) {
90
+ nodes {
91
+ ... on PullRequest {
92
+ number
93
+ url
94
+ headRefName
95
+ repository { nameWithOwner }
96
+ commits(last: 1) {
97
+ nodes {
98
+ commit {
99
+ checkSuites(first: 20) {
100
+ nodes {
101
+ status
102
+ conclusion
103
+ workflowRun {
104
+ url
105
+ createdAt
106
+ updatedAt
107
+ workflow { name }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ """
119
+
120
+
121
+ # U+FE0F, the emoji presentation selector. It renders the PRECEDING character
122
+ # as an emoji, which is two columns wide even when that base character is a
123
+ # narrow symbol - so it promotes rather than taking zero width of its own.
124
+ # Workflow names like "โœ”๏ธ Lint" and "โš ๏ธ Nightly" are exactly this shape.
125
+ VARIATION_SELECTOR_16 = "\ufe0f"
126
+
127
+
128
+ def display_width(text: str) -> int:
129
+ """Terminal columns ``text`` occupies.
130
+
131
+ The shell version counted 4-byte UTF-8 sequences and kept a hand-maintained
132
+ list of double-width BMP emoji, which had to grow every time a new icon was
133
+ added. ``unicodedata`` covers the common cases directly: wide and fullwidth
134
+ characters take two columns, combining marks take none, everything else
135
+ takes one.
136
+
137
+ Known limitation, unchanged from the shell version: a ZWJ sequence such as
138
+ "๐Ÿ‘จโ€๐Ÿ’ป" is counted per component rather than as one glyph, so it over-counts.
139
+ """
140
+ width = 0
141
+ previous = 0
142
+
143
+ for char in text:
144
+ if char == VARIATION_SELECTOR_16:
145
+ # Promote a narrow base to two columns; a base that is already
146
+ # wide stays wide.
147
+ if previous == 1:
148
+ width += 1
149
+ previous = 2
150
+ continue
151
+ if unicodedata.combining(char):
152
+ continue
153
+ previous = 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
154
+ width += previous
155
+
156
+ return width
157
+
158
+
159
+ def pad(value: str, width: int) -> str:
160
+ """Left-align ``value`` in a cell ``width`` terminal columns wide."""
161
+ return value + " " * max(0, width - display_width(value))
162
+
163
+
164
+ def pad_link(url: str, value: str, width: int) -> str:
165
+ """Same as :func:`pad`, but wraps the text in an OSC 8 hyperlink.
166
+
167
+ The escape sequences are zero-width, so the padding is computed from the
168
+ plain text and only the text itself gets wrapped. Suppressed alongside
169
+ color, so a piped or NO_COLOR run is plain text throughout.
170
+ """
171
+ padding = " " * max(0, width - display_width(value))
172
+ if not url or not color_enabled():
173
+ return value + padding
174
+ return f"\033]8;;{url}\033\\{value}\033]8;;\033\\{padding}"
175
+
176
+
177
+ def parse_iso(value: str) -> datetime | None:
178
+ """Parse GitHub's ISO-8601 UTC timestamps, or None if absent/unparseable."""
179
+ if not value:
180
+ return None
181
+ try:
182
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
183
+ except ValueError:
184
+ return None
185
+
186
+
187
+ def iso_to_age(value: str) -> str:
188
+ """Compact age since ``value``: "45s", "12m", "3h", "2d"."""
189
+ then = parse_iso(value)
190
+ if then is None:
191
+ return "-"
192
+
193
+ delta = max(0, int((datetime.now(UTC) - then).total_seconds()))
194
+ if delta < 60:
195
+ return f"{delta}s"
196
+ if delta < 3600:
197
+ return f"{delta // 60}m"
198
+ if delta < 86400:
199
+ return f"{delta // 3600}h"
200
+ return f"{delta // 86400}d"
201
+
202
+
203
+ def fmt_duration(seconds: int) -> str:
204
+ """Compact duration: "45s", "2m10s", "1h04m"."""
205
+ if seconds < 60:
206
+ return f"{seconds}s"
207
+ if seconds < 3600:
208
+ return f"{seconds // 60}m{seconds % 60:02d}s"
209
+ return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
210
+
211
+
212
+ def run_duration(status: str, created: str, updated: str) -> str:
213
+ """How long a run took.
214
+
215
+ Completed runs are createdAt -> updatedAt; anything still in flight is
216
+ measured against now, so the column ticks up while it runs.
217
+ """
218
+ start = parse_iso(created)
219
+ if start is None:
220
+ return "-"
221
+
222
+ if status in LIVE_STATUSES:
223
+ end = datetime.now(UTC)
224
+ else:
225
+ end = parse_iso(updated)
226
+ if end is None:
227
+ return "-"
228
+
229
+ return fmt_duration(max(0, int((end - start).total_seconds())))
230
+
231
+
232
+ def pick_suite(suites: list[dict]) -> dict | None:
233
+ """The check suite worth showing: in flight, else failed, else newest."""
234
+ usable = [s for s in suites if s.get("workflowRun")]
235
+ if not usable:
236
+ return None
237
+
238
+ def created(suite: dict) -> str:
239
+ return suite["workflowRun"].get("createdAt") or ""
240
+
241
+ # sorted(...)[-1], not max(...): jq's `sort_by | last` is a stable sort, so
242
+ # for suites sharing a createdAt it returns the LAST in document order,
243
+ # where max() returns the first. GitHub stamps every workflow run triggered
244
+ # by one push with the same second, so this tie fires constantly.
245
+ live = [s for s in usable if s.get("status") != "COMPLETED"]
246
+ if live:
247
+ return sorted(live, key=created)[-1]
248
+
249
+ bad = [s for s in usable if (s.get("conclusion") or "") in BAD_CONCLUSIONS]
250
+ if bad:
251
+ return sorted(bad, key=created)[-1]
252
+
253
+ return sorted(usable, key=created)[-1]
254
+
255
+
256
+ def build_rows(payload: dict) -> list[dict]:
257
+ """Flatten the GraphQL response into one row per PR.
258
+
259
+ This replaces the shell version's jq program, which is why jq is no longer
260
+ a dependency.
261
+ """
262
+ rows = []
263
+ nodes = (payload.get("data", {}).get("search", {}) or {}).get("nodes") or []
264
+
265
+ for pr in nodes:
266
+ if not pr or pr.get("number") is None:
267
+ continue
268
+
269
+ commits = (pr.get("commits") or {}).get("nodes") or []
270
+ suites: list[dict] = []
271
+ if commits:
272
+ commit = (commits[0] or {}).get("commit") or {}
273
+ suites = ((commit.get("checkSuites") or {}).get("nodes")) or []
274
+
275
+ suite = pick_suite(suites)
276
+
277
+ if suite is None:
278
+ status = "none"
279
+ workflow = "-"
280
+ created = updated = ""
281
+ url = pr.get("url") or ""
282
+ live = False
283
+ else:
284
+ workflow_run = suite["workflowRun"]
285
+ if suite.get("status") != "COMPLETED":
286
+ # `or "unknown"`: jq died outright on a null status, taking the
287
+ # whole run's output with it. An empty cell would be worse
288
+ # than saying so.
289
+ status = (suite.get("status") or "unknown").lower()
290
+ live = True
291
+ else:
292
+ status = (suite.get("conclusion") or "unknown").lower()
293
+ live = False
294
+ workflow = ((workflow_run.get("workflow") or {}).get("name")) or "-"
295
+ created = workflow_run.get("createdAt") or ""
296
+ updated = workflow_run.get("updatedAt") or ""
297
+ url = workflow_run.get("url") or ""
298
+
299
+ rows.append(
300
+ {
301
+ "repo": (pr.get("repository") or {}).get("nameWithOwner") or "",
302
+ "number": pr["number"],
303
+ "branch": pr.get("headRefName") or "",
304
+ "live": live,
305
+ "status": status,
306
+ "workflow": workflow,
307
+ "created": created,
308
+ "updated": updated,
309
+ "url": url,
310
+ }
311
+ )
312
+
313
+ return rows
314
+
315
+
316
+ def fetch_rows(owners: str, author: str, pr_limit: int) -> list[dict]:
317
+ """Every open PR and its head-commit check suites, in a single GraphQL call.
318
+
319
+ One call keeps a 15-second refresh well inside the rate limit.
320
+ """
321
+ # `user:` qualifiers are OR'd by GitHub search, so several owners widen the
322
+ # sweep rather than narrowing it.
323
+ query = f"is:pr is:open archived:false author:{author}"
324
+ for owner in owners.split(","):
325
+ owner = owner.strip()
326
+ if owner:
327
+ query += f" user:{owner}"
328
+
329
+ completed = run(
330
+ [
331
+ "gh",
332
+ "api",
333
+ "graphql",
334
+ "-f",
335
+ f"query={GRAPHQL_QUERY}",
336
+ "-f",
337
+ f"q={query}",
338
+ "-F",
339
+ f"first={pr_limit}",
340
+ ],
341
+ check=False,
342
+ capture=True,
343
+ )
344
+ if completed.returncode != 0:
345
+ return []
346
+
347
+ try:
348
+ payload = json.loads(completed.stdout or "{}")
349
+ except json.JSONDecodeError:
350
+ return []
351
+
352
+ return build_rows(payload)
353
+
354
+
355
+ def render(owners: str, author: str, pr_limit: int) -> None:
356
+ """Draw the table once."""
357
+ single_owner = "," not in owners
358
+
359
+ rows = fetch_rows(owners, author, pr_limit)
360
+ # After the fetch, not before: the shell called date(1) inside the banner,
361
+ # so in --watch mode the timestamp reflects when the data landed.
362
+ clock = datetime.now().strftime("%H:%M:%S")
363
+
364
+ if not rows:
365
+ note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
366
+ note(f"๐ŸŽฌ all-actions โ€” {clock}")
367
+ note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
368
+ info(f"๐Ÿซ™ No open pull requests authored by {author} under: {owners}")
369
+ return
370
+
371
+ # In-flight rows first, then grouped by repo, newest run first. Two passes
372
+ # because Python's sort is stable: the second pass preserves the ordering
373
+ # the first established within each group.
374
+ rows.sort(key=lambda r: r["created"] or "", reverse=True)
375
+ rows.sort(key=lambda r: (not r["live"], r["repo"]))
376
+
377
+ repo_count = len({r["repo"] for r in rows})
378
+
379
+ note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
380
+ note(f"๐ŸŽฌ all-actions โ€” {len(rows)} open PR(s) ยท {repo_count} repo(s) ยท {clock}")
381
+ note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
382
+
383
+ def label(row: dict) -> str:
384
+ return row["repo"].split("/", 1)[-1] if single_owner else row["repo"]
385
+
386
+ def status_cell(row: dict) -> str:
387
+ return f"{STATUS_ICONS.get(row['status'], 'โšช')} {row['status']}"
388
+
389
+ w_repo = max(4, *(display_width(label(r)) for r in rows))
390
+ w_pr = max(3, *(display_width(f"#{r['number']}") for r in rows))
391
+ w_status = max(6, *(display_width(status_cell(r)) for r in rows))
392
+ w_workflow = max(8, *(display_width(r["workflow"]) for r in rows))
393
+ w_branch = max(6, *(display_width(r["branch"]) for r in rows))
394
+ w_age, w_took = 4, 5
395
+
396
+ header = " ".join(
397
+ [
398
+ pad("REPO", w_repo),
399
+ pad("PR", w_pr),
400
+ pad("STATUS", w_status),
401
+ pad("WORKFLOW", w_workflow),
402
+ pad("BRANCH", w_branch),
403
+ pad("AGE", w_age),
404
+ "TOOK",
405
+ ]
406
+ )
407
+ colors = color_enabled()
408
+ print(f"{MAGENTA}{header}{NC}" if colors else header, flush=True)
409
+
410
+ live_count = 0
411
+ for row in rows:
412
+ cells = " ".join(
413
+ [
414
+ pad_link(row["url"], label(row), w_repo),
415
+ pad(f"#{row['number']}", w_pr),
416
+ pad(status_cell(row), w_status),
417
+ pad(row["workflow"], w_workflow),
418
+ pad(row["branch"], w_branch),
419
+ pad(iso_to_age(row["created"]), w_age),
420
+ pad(run_duration(row["status"], row["created"], row["updated"]), w_took),
421
+ ]
422
+ )
423
+ color = STATUS_COLORS.get(row["status"], NC)
424
+ print(f"{color}{cells}{NC}" if colors else cells, flush=True)
425
+ if row["live"]:
426
+ live_count += 1
427
+
428
+ idle_count = len(rows) - live_count
429
+ print(flush=True)
430
+ if live_count == 0:
431
+ success(f"๐Ÿ˜ด Nothing in flight โ€” {idle_count} open PR(s) showing their last run.")
432
+ else:
433
+ success(f"โ•โ•โ• {live_count} in flight ยท {idle_count} settled (last run) โ•โ•โ•")
434
+
435
+
436
+ def main(argv: list[str] | None = None) -> int:
437
+ parser = build_parser(
438
+ prog="all-actions",
439
+ description=(
440
+ "๐ŸŽฌ Show the latest GitHub Actions run for every open pull request you\n"
441
+ " authored, across every repo you own - one row per PR, so a repo with\n"
442
+ " five open PRs gets five rows. The repo name is a terminal hyperlink\n"
443
+ " to that run.\n\n"
444
+ " When a PR's head commit has several workflow runs, the row shows the\n"
445
+ " one worth acting on: anything still in flight, else a failure, else\n"
446
+ " the most recent run."
447
+ ),
448
+ )
449
+ parser.add_argument(
450
+ "--owner",
451
+ default="",
452
+ metavar="NAME",
453
+ help="Owner(s) to scan, comma-separated (default: your gh login)",
454
+ )
455
+ parser.add_argument(
456
+ "--author",
457
+ default="",
458
+ metavar="NAME",
459
+ help="PR author to match (default: your gh login)",
460
+ )
461
+ parser.add_argument(
462
+ "--pr-limit",
463
+ default="100",
464
+ metavar="N",
465
+ dest="pr_limit",
466
+ help="Open PRs to inspect, max 100 (default: 100)",
467
+ )
468
+ parser.add_argument(
469
+ "--interval",
470
+ default="15",
471
+ metavar="SECONDS",
472
+ help="Refresh interval used by --watch (default: 15)",
473
+ )
474
+ # --watch is special: it is both a bare flag and a flag that carries the
475
+ # interval, so it cannot use add_bool_flag. nargs="?" also accepts the
476
+ # space form `--watch 30`, which the shell version rejected.
477
+ parser.add_argument(
478
+ "--watch",
479
+ nargs="?",
480
+ const="",
481
+ default=None,
482
+ metavar="SECONDS",
483
+ help="Refresh every interval until interrupted",
484
+ )
485
+ args = parser.parse_args(argv)
486
+
487
+ # After parsing, unlike the shell version, so --help works without gh.
488
+ require_binary("gh", hint="Install it with `brew install gh`.")
489
+
490
+ interval_raw = args.interval
491
+ if args.watch:
492
+ interval_raw = args.watch
493
+
494
+ for name, value in (("pr-limit", args.pr_limit), ("interval", interval_raw)):
495
+ if not re.fullmatch(r"[0-9]+", value):
496
+ warning(f"โŒ --{name} must be a non-negative integer (got: {value})")
497
+ return 1
498
+
499
+ # The GraphQL search connection caps out at 100 nodes per page.
500
+ pr_limit = min(100, max(1, int(args.pr_limit)))
501
+ interval = max(1, int(interval_raw))
502
+ watch = args.watch is not None
503
+
504
+ if run(["gh", "auth", "status"], check=False, capture=True).returncode != 0:
505
+ warning("โŒ Not logged in to GitHub. Run: gh auth login")
506
+ return 1
507
+
508
+ owners = args.owner
509
+ author = args.author
510
+ if not owners or not author:
511
+ login = run_output(["gh", "api", "user", "--jq", ".login"], check=False).strip()
512
+ if not login:
513
+ # Without a login the query loses its `user:` qualifier entirely and
514
+ # silently searches all of GitHub. A token can pass `gh auth status`
515
+ # and still fail here (missing read:user, unauthorised SSO, a 5xx).
516
+ warning("โŒ Could not determine your gh login. Pass --owner= and --author=.")
517
+ return 1
518
+ owners = owners or login
519
+ author = author or login
520
+
521
+ if not watch:
522
+ render(owners, author, pr_limit)
523
+ return 0
524
+
525
+ try:
526
+ while True:
527
+ # ANSI clear + home, rather than shelling out to clear(1).
528
+ print("\033[2J\033[H", end="", flush=True)
529
+ try:
530
+ render(owners, author, pr_limit)
531
+ # Broad on purpose: the shell wrapped this as `render || true`.
532
+ except Exception as exc:
533
+ # One unexpected payload shape must not end a long watch.
534
+ warning(f"โš ๏ธ Render failed, retrying next cycle: {exc}")
535
+ info(f"๐Ÿ” Refreshing every {interval}s โ€” Ctrl-C to stop.")
536
+ time.sleep(interval)
537
+ except KeyboardInterrupt:
538
+ # Ctrl-C during a watch loop is the normal exit, not a failure.
539
+ print(flush=True)
540
+ success("๐Ÿ‘‹ Stopped watching.")
541
+ return 0
542
+
543
+
544
+ if __name__ == "__main__":
545
+ run_cli(main)
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """๐ŸŒฟ Check out recent remote branches authored by you that aren't yet local.
3
+
4
+ Usage:
5
+ checkout-my-branches.py [--author=EMAIL] [--limit=N]
6
+
7
+ Options:
8
+ --author=EMAIL Author email to match (default: git config user.email)
9
+ --limit=N How many recent remote branches to scan (default: 100)
10
+ --help Show help
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
20
+
21
+ from utils import (
22
+ build_parser,
23
+ info,
24
+ log,
25
+ note,
26
+ require_binary,
27
+ run,
28
+ run_cli,
29
+ run_lines,
30
+ run_output,
31
+ success,
32
+ warning,
33
+ )
34
+
35
+ __version__ = "3.0.0"
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ default_author = run_output(["git", "config", "user.email"], check=False).strip()
40
+
41
+ parser = build_parser(
42
+ prog="checkout-my-branches",
43
+ description="๐ŸŒฟ Check out recent remote branches you authored that aren't yet local.",
44
+ )
45
+ parser.add_argument(
46
+ "--author",
47
+ default=default_author,
48
+ metavar="EMAIL",
49
+ help="Author email to match (default: git config user.email)",
50
+ )
51
+ # Kept as a string, not `type=int`: argparse's own int conversion would
52
+ # own the failure and exit before the shell version's message could print.
53
+ parser.add_argument(
54
+ "--limit",
55
+ default="100",
56
+ metavar="N",
57
+ help="How many recent remote branches to scan (default: 100)",
58
+ )
59
+ args = parser.parse_args(argv)
60
+
61
+ if not args.author:
62
+ warning("โŒ No author email โ€” pass --author=EMAIL or set git config user.email")
63
+ return 1
64
+
65
+ if not re.fullmatch(r"[0-9]+", args.limit) or int(args.limit) < 1:
66
+ warning(f"โŒ --limit must be a positive integer (got: {args.limit})")
67
+ return 1
68
+ limit = int(args.limit)
69
+
70
+ require_binary("git")
71
+
72
+ note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
73
+ note("๐ŸŒฟ checkout-my-branches")
74
+ note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
75
+
76
+ if run_output(["git", "status", "--porcelain"]):
77
+ warning("โŒ Working tree is dirty. Commit or stash before running. ๐Ÿงบ")
78
+ return 1
79
+
80
+ info("๐Ÿ“ก Fetching remotes...")
81
+ run(["git", "fetch", "--all", "--prune", "--quiet"])
82
+
83
+ info(f"๐Ÿ“ฆ Scanning {limit} most recent remote branches for author ๐Ÿ‘ค {args.author}...")
84
+
85
+ checked_out = 0
86
+ skipped = 0
87
+ not_mine = 0
88
+
89
+ # One pass: author email + refname + symref target, newest first. %(symref)
90
+ # is non-empty for symbolic refs like origin/HEAD, which must be skipped -
91
+ # it is a pointer, not a branch anyone authored.
92
+ #
93
+ # Separator is \x01 rather than the shell version's '|': an author email or
94
+ # a branch name may contain a pipe, and the shell version then mis-split
95
+ # the record. Control characters are illegal in ref names, so \x01 is safe.
96
+ lines = run_lines(
97
+ [
98
+ "git",
99
+ "for-each-ref",
100
+ "--sort=-committerdate",
101
+ "--format=%(authoremail)%01%(refname:short)%01%(symref)",
102
+ "refs/remotes/origin",
103
+ ]
104
+ )
105
+
106
+ for line in lines[:limit]:
107
+ parts = line.split("\x01")
108
+ if len(parts) < 3:
109
+ continue
110
+ author_email, remote_branch, symref = parts[0], parts[1], parts[2]
111
+
112
+ if not remote_branch or symref:
113
+ continue
114
+
115
+ author_email = author_email.removeprefix("<").removesuffix(">")
116
+ if author_email != args.author:
117
+ not_mine += 1
118
+ continue
119
+
120
+ local_branch = remote_branch.removeprefix("origin/")
121
+ if not local_branch or local_branch == remote_branch:
122
+ continue # malformed entry โ€” don't touch
123
+
124
+ exists = run(
125
+ ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{local_branch}"],
126
+ check=False,
127
+ capture=True,
128
+ )
129
+ if exists.returncode == 0:
130
+ info(f"โญ๏ธ Already local: {local_branch}")
131
+ skipped += 1
132
+ else:
133
+ log(f"๐Ÿ†• Checking out: {local_branch}")
134
+ run(["git", "checkout", "-b", local_branch, "--track", remote_branch])
135
+ checked_out += 1
136
+
137
+ print()
138
+ success("โ•โ•โ• summary โ•โ•โ•")
139
+ success(f"๐Ÿ†• Checked out: {checked_out}")
140
+ success(f"โญ๏ธ Already local: {skipped}")
141
+ success(f"๐Ÿ‘ป Skipped (not yours): {not_mine}")
142
+ success("โœจ Done.")
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ run_cli(main)