@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
package/bin/utils.py ADDED
@@ -0,0 +1,836 @@
1
+ """Shared helpers for the scripts under ``bin/<category>/``.
2
+
3
+ This is the Python counterpart of the older ``bin/utils.sh``. Scripts pick it
4
+ up with the same shape the shell scripts used for sourcing::
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
10
+ from utils import info, warning, run
11
+
12
+ Everything here is standard library only, on purpose: the scripts have to run
13
+ from a bare ``python3`` on any machine this repo is cloned to, with no venv and
14
+ no install step.
15
+
16
+ =============================================================================
17
+ CLI conventions - shared patterns for bin/<category>/*.py
18
+
19
+ * Long flags only: ``--name=value`` for values (no space-separated ``-o val``
20
+ style in these scripts unless a specific tool documents otherwise).
21
+ * Booleans: a bare ``--flag`` enables; the same flag also honors
22
+ ``--flag=true|false`` (and yes|no, 1|0). ``add_bool_flag`` builds both forms.
23
+ A bare ``--flag`` never consumes the following argument, matching the shell
24
+ scripts' ``while [[ $# -gt 0 ]]`` loop.
25
+ * Help: ``-h`` / ``--help`` prints the script's usage.
26
+ * Parsing style: ``build_parser`` so every script handles argv the same way.
27
+ =============================================================================
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import os
35
+ import re
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ from collections.abc import Callable, Iterable, Iterator, Sequence
40
+ from fnmatch import fnmatchcase
41
+ from pathlib import Path
42
+ from typing import NoReturn
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Colors
46
+ # ---------------------------------------------------------------------------
47
+
48
+ NC = "\033[0m" # No Color
49
+ RED = "\033[1;31m"
50
+ GREEN = "\033[0;32m"
51
+ YELLOW = "\033[1;33m"
52
+ CYAN = "\033[1;36m"
53
+ MAGENTA = "\033[1;35m"
54
+
55
+ _FORCE_COLOR_OFF = {"", "0", "false", "no", "off"}
56
+
57
+
58
+ def _color_enabled() -> bool:
59
+ """Honor NO_COLOR and non-tty output the way a well-behaved CLI should.
60
+
61
+ DELIBERATE DIVERGENCE from ``utils.sh``, which always emitted escapes.
62
+ Suppressing them when stdout is not a terminal keeps captured output clean
63
+ for the tests and for shell pipelines. ``FORCE_COLOR`` overrides, following
64
+ the usual convention that ``FORCE_COLOR=0`` means off, not on.
65
+
66
+ The tty check happens at call time, not import time, so
67
+ ``contextlib.redirect_stdout`` and pytest's capture are both respected.
68
+ """
69
+ force = os.environ.get("FORCE_COLOR")
70
+ if force is not None and force.lower() not in _FORCE_COLOR_OFF:
71
+ return True
72
+ if os.environ.get("NO_COLOR"):
73
+ return False
74
+ try:
75
+ return sys.stdout.isatty()
76
+ except (ValueError, AttributeError):
77
+ return False
78
+
79
+
80
+ def color_enabled() -> bool:
81
+ """Public form of :func:`_color_enabled`, for callers that build their own
82
+ escape sequences (per-language colouring, table headers) and must honour
83
+ ``NO_COLOR`` the same way the log helpers do."""
84
+ return _color_enabled()
85
+
86
+
87
+ def log_color(color: str, message: str) -> None:
88
+ """Print ``message`` wrapped in ``color``.
89
+
90
+ The shell original used ``printf '%b%b%b\\n'``, whose ``%b`` also expanded
91
+ backslash escapes *inside the message* - so a Windows path or a regex in a
92
+ log line came out mangled, and a stray ``\\c`` truncated the output. That
93
+ escape expansion is deliberately not reproduced; messages print literally.
94
+
95
+ Flushed on every line. Python block-buffers stdout when it is a pipe, but
96
+ a subprocess writes its stderr straight to fd 2 - without the flush, git's
97
+ error messages appear *before* the banner that introduced them whenever the
98
+ output is piped to a file or a CI log.
99
+ """
100
+ if _color_enabled():
101
+ print(f"{color}{message}{NC}", flush=True)
102
+ else:
103
+ print(message, flush=True)
104
+
105
+
106
+ def log(message: str) -> None:
107
+ """Green. General progress."""
108
+ log_color(GREEN, message)
109
+
110
+
111
+ def warning(message: str) -> None:
112
+ """Red. Errors and destructive-action notices."""
113
+ log_color(RED, message)
114
+
115
+
116
+ def info(message: str) -> None:
117
+ """Cyan. Informational detail."""
118
+ log_color(CYAN, message)
119
+
120
+
121
+ def note(message: str) -> None:
122
+ """Yellow. Headers and secondary detail."""
123
+ log_color(YELLOW, message)
124
+
125
+
126
+ def success(message: str) -> None:
127
+ """Magenta. Summaries and completion."""
128
+ log_color(MAGENTA, message)
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Formatting
133
+ # ---------------------------------------------------------------------------
134
+
135
+ _KB = 1024
136
+ _MB = _KB * 1024
137
+ _GB = _MB * 1024
138
+ _TB = _GB * 1024
139
+ _PB = _TB * 1024
140
+
141
+ _UNITS: tuple[tuple[int, str], ...] = (
142
+ (_PB, "PB"),
143
+ (_TB, "TB"),
144
+ (_GB, "GB"),
145
+ (_MB, "MB"),
146
+ (_KB, "KB"),
147
+ )
148
+
149
+
150
+ def format_bytes(num_bytes: int) -> str:
151
+ """Format a byte count as PB/TB/GB/MB/KB, or plain bytes below 1 KB.
152
+
153
+ Truncates rather than rounds, matching ``utils.sh``, which piped through
154
+ ``bc`` with ``scale=2``. ``2043`` bytes is ``1.99512...`` KB and renders as
155
+ ``1.99 KB``, not ``2.00 KB``. The arithmetic is exact integer maths, so the
156
+ truncation holds at every magnitude.
157
+
158
+ Output is identical to the shell version for every value representable in
159
+ bash's 64-bit signed arithmetic. Above 2^63 bash wrapped and fell through
160
+ to a raw byte count; this version keeps formatting correctly.
161
+ """
162
+ num_bytes = int(num_bytes)
163
+
164
+ for size, unit in _UNITS:
165
+ if num_bytes >= size:
166
+ # Integer maths, so the truncation is exact - Decimal's default
167
+ # 28-digit context would round before the truncation could apply.
168
+ hundredths = (num_bytes * 100) // size
169
+ return f"{hundredths // 100}.{hundredths % 100:02d} {unit}"
170
+
171
+ return f"{num_bytes} B"
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Argument parsing
176
+ # ---------------------------------------------------------------------------
177
+
178
+ _TRUE_VALUES = {"true", "yes", "1", "on"}
179
+ _FALSE_VALUES = {"false", "no", "0", "off"}
180
+
181
+ # Suffix for the hidden companion option that sets a bool flag to False.
182
+ # Deliberately ugly so it cannot collide with a real flag name.
183
+ _FALSE_SUFFIX = "-set-false-internal"
184
+
185
+
186
+ def parse_bool(value: str | bool) -> bool:
187
+ """Parse a boolean spelling.
188
+
189
+ Accepts ``true|yes|1|on`` and ``false|no|0|off``, case-insensitive, and
190
+ raises ``ValueError`` on anything else - so a typo like ``--dry-run=ture``
191
+ is a hard error rather than a silent falsy that deletes the user's files.
192
+
193
+ This is intentionally WIDER than the shell scripts, which tested dry-run
194
+ with ``if $DRY_RUN; then`` - literally executing the value as a command.
195
+ There ``true``/``false`` worked, ``1``/``0``/``on``/``off``/``no`` were
196
+ "command not found", and ``yes`` invoked ``/usr/bin/yes`` and hung forever.
197
+ """
198
+ if isinstance(value, bool):
199
+ return value
200
+
201
+ lowered = value.strip().lower()
202
+ if lowered in _TRUE_VALUES:
203
+ return True
204
+ if lowered in _FALSE_VALUES:
205
+ return False
206
+
207
+ raise ValueError(f"expected true/false (got: {value!r})")
208
+
209
+
210
+ class BoolFlagParser(argparse.ArgumentParser):
211
+ """``ArgumentParser`` that handles ``--flag`` and ``--flag=value`` safely.
212
+
213
+ argparse has no built-in for that pair. The obvious spelling,
214
+ ``nargs="?"``, is a trap: it makes a bare ``--flag`` swallow the *next*
215
+ argument whenever that argument parses as a boolean, so ``--dry-run 0``
216
+ silently becomes ``dry_run=False`` and deletes for real. The shell scripts
217
+ could never do that.
218
+
219
+ Instead, ``--flag=value`` tokens are rewritten before argparse sees them:
220
+ to ``--flag`` (a plain ``store_true``) for a true value, and to a hidden
221
+ ``store_false`` companion for a false one. Rewriting in place keeps
222
+ left-to-right precedence, so a later flag still wins over an earlier one.
223
+ """
224
+
225
+ def __init__(self, *args, **kwargs) -> None:
226
+ super().__init__(*args, **kwargs)
227
+ self._bool_flags: dict[str, str] = {} # "--dry-run" -> "--dry-run-set-false-internal"
228
+
229
+ def register_bool_flag(self, name: str, false_name: str) -> None:
230
+ self._bool_flags[name] = false_name
231
+
232
+ def _rewrite(self, argv: Sequence[str]) -> list[str]:
233
+ rewritten: list[str] = []
234
+ for index, arg in enumerate(argv):
235
+ # Everything after a bare `--` is a literal positional, so it must
236
+ # pass through untouched - rewriting it would report an error
237
+ # naming an internal flag the user never typed.
238
+ if arg == "--":
239
+ rewritten.extend(argv[index:])
240
+ break
241
+
242
+ flag, sep, value = arg.partition("=")
243
+ if not sep or flag not in self._bool_flags:
244
+ rewritten.append(arg)
245
+ continue
246
+ try:
247
+ enabled = parse_bool(value)
248
+ except ValueError as exc:
249
+ self.error(f"argument {flag}: {exc}")
250
+ rewritten.append(flag if enabled else self._bool_flags[flag])
251
+ return rewritten
252
+
253
+ def parse_args(self, args=None, namespace=None): # type: ignore[override]
254
+ argv = list(sys.argv[1:] if args is None else args)
255
+ return super().parse_args(self._rewrite(argv), namespace)
256
+
257
+ def parse_known_args(self, args=None, namespace=None): # type: ignore[override]
258
+ argv = list(sys.argv[1:] if args is None else args)
259
+ return super().parse_known_args(self._rewrite(argv), namespace)
260
+
261
+ def error(self, message: str) -> NoReturn:
262
+ """Print the message plus full usage and exit 1, as the shell did.
263
+
264
+ argparse's default is a one-line usage and exit code 2. Every shell
265
+ script here printed ``❌ Unknown argument: X`` followed by the whole
266
+ usage block and exited 1, and the ``.zshrc`` chains distinguish 1 from
267
+ other codes.
268
+
269
+ Annotated ``NoReturn`` so callers - notably ``_rewrite``, which falls
270
+ through to use a variable this branch never assigns - are provably safe.
271
+ """
272
+ # The hidden companion option is an implementation detail; a user who
273
+ # typed `--dry-run=false` should not be told about it by name.
274
+ warning(f"❌ {message.replace(_FALSE_SUFFIX, '')}")
275
+ self.print_help()
276
+ sys.exit(1)
277
+
278
+
279
+ def build_parser(prog: str, description: str, epilog: str = "") -> BoolFlagParser:
280
+ """A parser pre-set to this repo's conventions.
281
+
282
+ ``allow_abbrev=False`` matters: without it argparse silently accepts
283
+ ``--dry`` for ``--dry-run``, which the shell scripts never did.
284
+ """
285
+ return BoolFlagParser(
286
+ prog=prog,
287
+ description=description,
288
+ epilog=epilog,
289
+ formatter_class=argparse.RawDescriptionHelpFormatter,
290
+ allow_abbrev=False,
291
+ )
292
+
293
+
294
+ def add_bool_flag(
295
+ parser: BoolFlagParser,
296
+ name: str,
297
+ *,
298
+ default: bool = False,
299
+ help: str = "",
300
+ dest: str | None = None,
301
+ allow_value: bool = True,
302
+ ) -> None:
303
+ """Add a flag accepting both ``--flag`` and ``--flag=true|false``.
304
+
305
+ Both spellings are needed in most places: the shell scripts supported them,
306
+ and the ``.zshrc`` wrappers rely on the ``=value`` form to flip dry-run
307
+ defaults. See :class:`BoolFlagParser` for why this is not ``nargs="?"``.
308
+
309
+ Pass ``allow_value=False`` for an inverted flag such as ``--no-force``.
310
+ The shell scripts only ever accepted the bare spelling for those, and
311
+ ``--no-force=false`` is a double negative nobody should have to decode: it
312
+ would silently mean "do force", which is the opposite of how it reads on a
313
+ script that unmounts volumes.
314
+ """
315
+ dest = dest or name.removeprefix("--").replace("-", "_")
316
+
317
+ parser.add_argument(name, action="store_true", dest=dest, default=default, help=help)
318
+
319
+ if not allow_value:
320
+ return
321
+
322
+ false_name = f"{name}{_FALSE_SUFFIX}"
323
+ parser.add_argument(
324
+ false_name, action="store_false", dest=dest, default=default, help=argparse.SUPPRESS
325
+ )
326
+ parser.register_bool_flag(name, false_name)
327
+
328
+
329
+ def env_bool(name: str, default: bool) -> bool:
330
+ """Read a boolean from the environment, for the ``DRY_RUN=true`` wrappers.
331
+
332
+ An unset or empty variable falls back to ``default``. An unparseable value
333
+ exits non-zero rather than guessing, because guessing wrong on ``DRY_RUN``
334
+ means deleting files the user expected only to preview.
335
+ """
336
+ raw = os.environ.get(name)
337
+ if raw is None or raw == "":
338
+ return default
339
+ try:
340
+ return parse_bool(raw)
341
+ except ValueError as exc:
342
+ warning(f"❌ Environment variable {name}: {exc}")
343
+ sys.exit(1)
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # Subprocess
348
+ # ---------------------------------------------------------------------------
349
+
350
+
351
+ def require_binary(name: str, *, hint: str = "") -> str:
352
+ """Exit with a clear message when an external tool is missing.
353
+
354
+ Serves the same role as the ``require_binary`` helper in
355
+ ``bin/video/find-video-mkv-issues.sh``, with a friendlier message and an
356
+ optional install hint. Returns the resolved path so the caller can pass an
357
+ absolute program name to :func:`run`.
358
+
359
+ Note this resolves PATH executables only, where bash's ``command -v`` also
360
+ found builtins, functions and aliases. Every binary this repo requires is a
361
+ real executable, so the distinction does not bite in practice.
362
+ """
363
+ resolved = shutil.which(name)
364
+ if resolved is None:
365
+ warning(f"❌ '{name}' is not installed or not in PATH.")
366
+ if hint:
367
+ info(f"💡 {hint}")
368
+ sys.exit(1)
369
+ return resolved
370
+
371
+
372
+ def run(
373
+ argv: Sequence[str],
374
+ *,
375
+ check: bool = True,
376
+ capture: bool = False,
377
+ cwd: str | Path | None = None,
378
+ text: bool = True,
379
+ env: dict[str, str] | None = None,
380
+ stdin: int | None = None,
381
+ timeout: float | None = None,
382
+ ) -> subprocess.CompletedProcess[str]:
383
+ """Run an external command.
384
+
385
+ Always an argv list, never a string, and never ``shell=True`` - the two
386
+ rules that keep quoting bugs and injection out of a script that handles
387
+ arbitrary filenames. ``check=True`` by default so a failure raises instead
388
+ of being sailed past, which is what ``set -e`` bought the shell versions.
389
+
390
+ ``env`` ADDS to the current environment rather than replacing it, matching
391
+ the shell idiom ``VAR=x cmd``. Replacing it outright would drop
392
+ ``/opt/homebrew/bin`` from PATH and make ffmpeg and friends vanish.
393
+ """
394
+ merged_env = {**os.environ, **env} if env else None
395
+
396
+ return subprocess.run( # noqa: S603 - argv list, shell=False, by construction
397
+ list(argv),
398
+ check=check,
399
+ capture_output=capture,
400
+ cwd=cwd,
401
+ text=text,
402
+ # errors="replace" rather than the default "strict": a single
403
+ # non-UTF-8 byte in a child's diagnostic (a legacy-encoded title tag
404
+ # echoed back by ffmpeg, say) would otherwise raise UnicodeDecodeError
405
+ # and abort a whole library scan partway through.
406
+ errors="replace" if text else None,
407
+ env=merged_env,
408
+ stdin=stdin,
409
+ timeout=timeout,
410
+ )
411
+
412
+
413
+ def run_output(argv: Sequence[str], *, check: bool = True, cwd: str | Path | None = None) -> str:
414
+ """Run a command and return its stdout with the trailing newline stripped.
415
+
416
+ The equivalent of ``$(cmd)``. With ``check=False`` this is the equivalent of
417
+ ``$(cmd) || true``: a non-zero exit still returns whatever stdout was
418
+ produced, and a missing binary returns "" instead of raising.
419
+ """
420
+ try:
421
+ completed = run(argv, check=check, capture=True, cwd=cwd)
422
+ except FileNotFoundError:
423
+ if check:
424
+ raise
425
+ return ""
426
+ return (completed.stdout or "").rstrip("\n")
427
+
428
+
429
+ def run_lines(
430
+ argv: Sequence[str], *, check: bool = True, cwd: str | Path | None = None
431
+ ) -> list[str]:
432
+ """Run a command and return stdout split into non-empty lines.
433
+
434
+ DELIBERATE DIVERGENCE: in the shell scripts these reads sat inside
435
+ ``< <(...)`` process substitution, whose failure does NOT trip ``set -e`` -
436
+ a failing ``git for-each-ref`` just looked like "no branches" and the script
437
+ exited 0. Here a failure raises.
438
+ """
439
+ output = run_output(argv, check=check, cwd=cwd)
440
+ return [line for line in output.split("\n") if line]
441
+
442
+
443
+ def run_cli(entry: Callable[[], int]) -> None:
444
+ """Run a script's ``main`` and translate exceptions into shell exit codes.
445
+
446
+ Every script ends with ``run_cli(main)`` so error handling is identical
447
+ across the repo, rather than each file growing its own ``try/except``.
448
+ """
449
+ try:
450
+ sys.exit(entry())
451
+ except subprocess.CalledProcessError as exc:
452
+ # Surface the child's own diagnostic. The shell versions let stderr
453
+ # through to the terminal; capturing it and printing only "command
454
+ # failed" would hide git's actual explanation.
455
+ if getattr(exc, "stderr", None):
456
+ sys.stderr.write(exc.stderr)
457
+ warning(f"❌ Command failed: {' '.join(str(part) for part in exc.cmd)}")
458
+ code = exc.returncode
459
+ # A negative returncode means the child died on a signal; shells report
460
+ # that as 128+N, and sys.exit(-9) would otherwise become 247.
461
+ sys.exit(128 - code if code and code < 0 else (code or 1))
462
+ except FileNotFoundError as exc:
463
+ warning(f"❌ {exc}")
464
+ sys.exit(127)
465
+ except OSError as exc:
466
+ warning(f"❌ {exc}")
467
+ sys.exit(1)
468
+ except KeyboardInterrupt:
469
+ sys.exit(130)
470
+
471
+
472
+ # ---------------------------------------------------------------------------
473
+ # Worktrees
474
+ #
475
+ # A branch checked out in a linked worktree cannot be checked out, deleted or
476
+ # reset from the main clone - git refuses with "already used by worktree".
477
+ # That is a mechanical limit, not a conflict, so every script that touches
478
+ # branches needs to know which ones are pinned and where.
479
+ # ---------------------------------------------------------------------------
480
+
481
+
482
+ def worktree_branch_map(cwd: str | Path | None = None) -> dict[str, str]:
483
+ """Map ``branch -> worktree path`` for every worktree with a branch checked out.
484
+
485
+ Detached worktrees are skipped (they pin no branch). The main worktree IS
486
+ included, because its branch is pinned for exactly the same reason.
487
+
488
+ DELIBERATE DIVERGENCE: ``utils.sh`` emitted "<branch>\t<path>" lines that
489
+ every caller re-parsed. A dict is the same data without the round trip, and
490
+ it cannot be truncated by a path containing a tab.
491
+ """
492
+ mapping: dict[str, str] = {}
493
+ path = ""
494
+ branch = ""
495
+
496
+ def flush() -> None:
497
+ if path and branch:
498
+ # First writer wins: a branch can only be pinned by one worktree,
499
+ # so a duplicate would mean git contradicted itself.
500
+ mapping.setdefault(branch, path)
501
+
502
+ for line in run_output(["git", "worktree", "list", "--porcelain"], cwd=cwd).splitlines():
503
+ if line.startswith("worktree "):
504
+ # A new record starts; flush the previous one.
505
+ flush()
506
+ path = line.removeprefix("worktree ")
507
+ branch = ""
508
+ elif line.startswith("branch refs/heads/"):
509
+ branch = line.removeprefix("branch refs/heads/")
510
+
511
+ flush()
512
+ return mapping
513
+
514
+
515
+ def worktree_for_branch(branch: str, cwd: str | Path | None = None) -> str:
516
+ """The worktree path holding ``branch``, or "" when no worktree does."""
517
+ return worktree_branch_map(cwd).get(branch, "")
518
+
519
+
520
+ def worktree_is_clean(path: str | Path) -> bool:
521
+ """True when the worktree at ``path`` has no modified or untracked files.
522
+
523
+ Ignored build output (node_modules, .next) does not count as dirty, because
524
+ ``git status --porcelain`` does not list ignored paths.
525
+ """
526
+ completed = run(["git", "-C", str(path), "status", "--porcelain"], check=False, capture=True)
527
+ # A non-zero exit means the path is not a working tree at all. Treating
528
+ # that as "clean" would let a caller delete a directory it never inspected.
529
+ if completed.returncode != 0:
530
+ return False
531
+ return not (completed.stdout or "").strip()
532
+
533
+
534
+ def branch_is_pushed(branch: str, cwd: str | Path | None = None) -> bool:
535
+ """True when every commit on ``branch`` is already on its remote.
536
+
537
+ Prefers the configured upstream and falls back to ``origin/<branch>``.
538
+ """
539
+ upstream = run_output(
540
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", f"{branch}@{{upstream}}"],
541
+ check=False,
542
+ cwd=cwd,
543
+ ).strip()
544
+ if not upstream:
545
+ upstream = f"origin/{branch}"
546
+
547
+ exists = run(
548
+ ["git", "rev-parse", "--verify", "--quiet", upstream], check=False, capture=True, cwd=cwd
549
+ )
550
+ if exists.returncode != 0:
551
+ return False
552
+
553
+ ancestor = run(
554
+ ["git", "merge-base", "--is-ancestor", branch, upstream],
555
+ check=False,
556
+ capture=True,
557
+ cwd=cwd,
558
+ )
559
+ return ancestor.returncode == 0
560
+
561
+
562
+ def worktree_paths(cwd: str | Path | None = None) -> list[str]:
563
+ """Every worktree path in ``git worktree list --porcelain`` order.
564
+
565
+ The first entry is always the main worktree; callers drop it themselves so
566
+ the distinction stays explicit at the call site.
567
+
568
+ The shell version parsed this with ``awk '{print $2}'``, which splits on
569
+ whitespace and so truncated any worktree path containing a space. Taking
570
+ the whole remainder of the line keeps such paths intact.
571
+ """
572
+ return [
573
+ line.removeprefix("worktree ")
574
+ for line in run_output(["git", "worktree", "list", "--porcelain"], cwd=cwd).splitlines()
575
+ if line.startswith("worktree ")
576
+ ]
577
+
578
+
579
+ def main_worktree(cwd: str | Path | None = None) -> str:
580
+ """The main worktree's path - the first record git lists, always."""
581
+ paths = worktree_paths(cwd)
582
+ return paths[0] if paths else ""
583
+
584
+
585
+ def find_main_branch(cwd: str | Path | None = None) -> str:
586
+ """The local main branch, preferring ``main`` over ``master``, or "" for neither.
587
+
588
+ Takes an explicit ``cwd`` because callers run it after moving to the main
589
+ worktree; relying on the process working directory would make the answer
590
+ depend on where the script happened to be invoked from.
591
+ """
592
+ for candidate in ("main", "master"):
593
+ exists = run(
594
+ ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{candidate}"],
595
+ check=False,
596
+ capture=True,
597
+ cwd=cwd,
598
+ )
599
+ if exists.returncode == 0:
600
+ return candidate
601
+ return ""
602
+
603
+
604
+ # ---------------------------------------------------------------------------
605
+ # ffprobe
606
+ # ---------------------------------------------------------------------------
607
+
608
+
609
+ def ffprobe_entries(
610
+ path: Path,
611
+ entries: str,
612
+ *,
613
+ stream: str | None = None,
614
+ fmt: str = "csv=p=0",
615
+ on_error: str = "",
616
+ ) -> str:
617
+ """Read metadata out of a media file with ffprobe.
618
+
619
+ ``entries`` is passed straight to ``-show_entries`` (e.g.
620
+ ``stream=codec_name``). ``stream`` selects a stream when given (e.g.
621
+ ``v:0``).
622
+
623
+ Returns ``on_error`` when ffprobe itself fails, and "" when it succeeds but
624
+ the field is absent. Those are different situations - a corrupt file versus
625
+ a file with no audio track - and callers need to tell them apart.
626
+ """
627
+ argv = ["ffprobe", "-v", "error"]
628
+ if stream is not None:
629
+ argv += ["-select_streams", stream]
630
+ argv += ["-show_entries", entries, "-of", fmt, str(path)]
631
+
632
+ completed = run(argv, check=False, capture=True)
633
+ if completed.returncode != 0:
634
+ return on_error
635
+ return (completed.stdout or "").strip()
636
+
637
+
638
+ def ffprobe_json(path: Path, argv_extra: Sequence[str]) -> dict:
639
+ """Run ffprobe with ``-of json`` and parse the result.
640
+
641
+ Returns an empty dict when ffprobe fails or emits unparseable output, so
642
+ callers can treat an unreadable file as "no streams" rather than crashing
643
+ partway through a large library scan.
644
+ """
645
+ argv = ["ffprobe", "-v", "error", *argv_extra, "-of", "json", str(path)]
646
+ completed = run(argv, check=False, capture=True)
647
+ if completed.returncode != 0:
648
+ return {}
649
+ try:
650
+ parsed = json.loads(completed.stdout or "{}")
651
+ except json.JSONDecodeError:
652
+ return {}
653
+ return parsed if isinstance(parsed, dict) else {}
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # Filesystem
658
+ # ---------------------------------------------------------------------------
659
+
660
+
661
+ def is_macos_metadata(path: Path) -> bool:
662
+ """True for the AppleDouble sidecars (``._foo``) the shell scripts skipped.
663
+
664
+ ``.DS_Store`` is deliberately not included: the scripts that skip metadata
665
+ are matching the ``._*`` pattern specifically.
666
+ """
667
+ return path.name.startswith("._")
668
+
669
+
670
+ def iter_files(
671
+ root: Path,
672
+ *,
673
+ extensions: Iterable[str] | None = None,
674
+ recursive: bool = True,
675
+ skip_macos_metadata: bool = True,
676
+ skip_hidden: bool = False,
677
+ case_sensitive: bool = False,
678
+ ) -> Iterator[Path]:
679
+ """Walk ``root`` yielding files, the replacement for ``find ... -print0``.
680
+
681
+ Streams like ``find`` did: constant memory, first result immediately, and
682
+ entries are printed as they are found rather than after a full scan. On a
683
+ multi-TB media library that is the difference between instant output and a
684
+ minutes-long silent stall. Order within each directory is sorted, so runs
685
+ are reproducible without buffering the whole tree.
686
+
687
+ ``extensions`` is matched against the end of the FILENAME, reproducing
688
+ ``find -iname '*.ext'`` exactly - including for a file literally named
689
+ ``.mkv``, which a ``Path.suffix`` check would miss because pathlib treats a
690
+ leading dot as the stem. Pass ``case_sensitive=True`` for the handful of
691
+ callers whose shell original used ``-name`` rather than ``-iname``.
692
+
693
+ Only regular files are yielded. ``find -type f`` excluded FIFOs, sockets
694
+ and devices, and a FIFO reaching an ``open()`` blocks forever - which on a
695
+ hashing pass would hang the whole scan.
696
+
697
+ Symlinked files are skipped and symlinked directories are not followed,
698
+ matching ``find``'s default (no ``-L``).
699
+
700
+ Raises ``FileNotFoundError`` when ``root`` is not a directory. ``find``
701
+ errored there too, and under ``set -e`` that aborted the run; silently
702
+ yielding nothing would make a typo'd ``--path`` look like a clean library.
703
+ """
704
+ if not root.is_dir():
705
+ raise FileNotFoundError(f"Not a directory: {root}")
706
+
707
+ patterns: tuple[str, ...] | None = None
708
+ if extensions is not None:
709
+ patterns = tuple(f"*.{ext}" if case_sensitive else f"*.{ext.lower()}" for ext in extensions)
710
+
711
+ def _on_error(exc: OSError) -> None:
712
+ # find(1) printed "Permission denied" to stderr and carried on. Staying
713
+ # silent would make a half-readable SMB share look fully scanned.
714
+ # stderr, not the usual warning() helper, so these lines never
715
+ # interleave into output a caller is piping.
716
+ print(f"find: {exc.filename}: {exc.strerror}", file=sys.stderr, flush=True)
717
+
718
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False, onerror=_on_error):
719
+ dirnames.sort()
720
+ if skip_hidden:
721
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
722
+ filenames.sort()
723
+
724
+ directory = Path(dirpath)
725
+ for name in filenames:
726
+ if skip_macos_metadata and name.startswith("._"):
727
+ continue
728
+ if skip_hidden and name.startswith("."):
729
+ continue
730
+ candidate = name if case_sensitive else name.lower()
731
+ if patterns is not None and not any(
732
+ fnmatchcase(candidate, pattern) for pattern in patterns
733
+ ):
734
+ continue
735
+ path = directory / name
736
+ if path.is_symlink() or not path.is_file():
737
+ continue
738
+ yield path
739
+
740
+ if not recursive:
741
+ dirnames[:] = []
742
+
743
+
744
+ def scan_root(raw: str, *, flag: str = "--path") -> Path:
745
+ """Validate a user-supplied scan root, or exit with a clear message.
746
+
747
+ Refuses a symlinked root. That is not fussiness: ``find`` does not follow a
748
+ symlinked START POINT without ``-L``, so every shell script here was a
749
+ silent no-op when pointed at one. Quietly descending instead would turn
750
+ ``delete-by-ext --path=~/media`` (a symlink to the NAS) from "does nothing"
751
+ into "deletes the library". Refusing is the only safe reading of a case the
752
+ shell version never actually handled.
753
+ """
754
+ if not raw:
755
+ warning(f"❌ {flag} is required")
756
+ sys.exit(1)
757
+
758
+ path = Path(raw)
759
+
760
+ if path.is_symlink():
761
+ warning(f"❌ {flag} is a symlink: {raw}")
762
+ info("💡 find(1) does not follow a symlinked start point, so this was")
763
+ info(" previously a no-op. Pass the real path to act on it.")
764
+ sys.exit(1)
765
+
766
+ if not path.is_dir():
767
+ warning(f"❌ Directory not found: {raw}")
768
+ sys.exit(1)
769
+
770
+ return path
771
+
772
+
773
+ def normalize_extensions(raw: str, *, flag: str = "--ext") -> list[str]:
774
+ """Split a comma-separated extension list, preserving glob metacharacters.
775
+
776
+ Values are passed through verbatim (minus surrounding whitespace) so
777
+ ``--ext=mp*`` still matches mp4/mp3/mpg, exactly as the shell version's
778
+ ``-iname "*.$e"`` interpolation did.
779
+
780
+ A leading dot is REJECTED rather than stripped. ``--ext=.jpg`` built
781
+ ``-iname "*..jpg"`` in the shell version, which matched nothing - so
782
+ stripping the dot would silently turn a harmless typo into a mass delete,
783
+ and keeping it would silently match nothing. An error is the honest answer.
784
+ """
785
+ extensions = [part.strip() for part in raw.split(",")]
786
+ extensions = [ext for ext in extensions if ext]
787
+
788
+ for ext in extensions:
789
+ if ext.startswith("."):
790
+ warning(f"❌ {flag} entries must not start with a dot (got: {ext!r})")
791
+ info(f"💡 Use {flag}={ext.lstrip('.')} instead.")
792
+ sys.exit(1)
793
+
794
+ return extensions
795
+
796
+
797
+ _SIZE_RE = re.compile(r"([0-9]+)([a-z]*)")
798
+
799
+
800
+ def parse_size(text: str) -> int:
801
+ """Parse a human size like ``1MB``/``500k``/``2 GiB`` into bytes.
802
+
803
+ Ports ``parse_bytes`` from ``files-under-size.sh``, and deliberately keeps
804
+ its unit set: bytes, KB, MB, GB. Terabytes are NOT accepted - the shell
805
+ version rejected them, and silently widening the accepted range of a flag
806
+ that drives ``rm`` is how ``--size=1t`` turns into a deleted library.
807
+
808
+ Binary units throughout (KB == KiB == 1024), as in the shell version.
809
+ Unlike the shell version, a leading zero is decimal, not octal (``010mb``
810
+ is 10 MB, not 8 MB), and a signed value is rejected outright.
811
+ """
812
+ cleaned = "".join(text.split()).lower()
813
+ match = _SIZE_RE.fullmatch(cleaned)
814
+ if match is None:
815
+ raise ValueError(f"Invalid size: {text!r}")
816
+
817
+ digits, unit = match.groups()
818
+
819
+ multipliers = {
820
+ "": 1,
821
+ "b": 1,
822
+ "k": _KB,
823
+ "kb": _KB,
824
+ "kib": _KB,
825
+ "m": _MB,
826
+ "mb": _MB,
827
+ "mib": _MB,
828
+ "g": _GB,
829
+ "gb": _GB,
830
+ "gib": _GB,
831
+ }
832
+
833
+ if unit not in multipliers:
834
+ raise ValueError(f"Invalid size unit in {text!r}")
835
+
836
+ return int(digits) * multipliers[unit]