@codebend3r/che 1.1.0 → 1.2.1
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.
- package/README.md +58 -26
- package/bin/art.py +22 -20
- package/bin/branchsync.py +324 -0
- package/bin/che.py +208 -587
- package/bin/commands.py +219 -231
- package/bin/doctor.py +220 -0
- package/bin/drives/eject-all-drives.py +21 -38
- package/bin/drives/mount-all-drives.py +18 -62
- package/bin/drives/ping-nas.py +77 -105
- package/bin/files/compress-folders.py +7 -11
- package/bin/files/delete-by-ext.py +10 -14
- package/bin/files/delete-empty-folders.py +33 -37
- package/bin/files/delete-smb-files.py +12 -21
- package/bin/files/files-under-size.py +5 -11
- package/bin/files/find-largest-files.py +21 -25
- package/bin/files/list-permission.py +40 -0
- package/bin/files/make-alpha-dir.py +2 -0
- package/bin/git/all-actions.py +350 -253
- package/bin/git/checkout-my-branches.py +42 -63
- package/bin/git/clean-stale-branches.py +23 -48
- package/bin/git/prune-worktrees.py +12 -51
- package/bin/git/sync-all-branches.py +75 -297
- package/bin/git/update-local-branches.py +67 -73
- package/bin/gitutils.py +263 -0
- package/bin/install.py +393 -1182
- package/bin/machine.py +345 -0
- package/bin/media.py +127 -0
- package/bin/menu.py +437 -0
- package/bin/nas.py +102 -0
- package/bin/rcfile.py +380 -0
- package/bin/shellgen.py +503 -431
- package/bin/system/btop-launch.py +17 -13
- package/bin/system/update-brew.py +57 -68
- package/bin/tui.py +156 -153
- package/bin/utils.py +165 -237
- package/bin/video/delete-duplicate-videos.py +64 -62
- package/bin/video/detect-green-magenta-videos.py +105 -95
- package/bin/video/find-movie-by-year.py +3 -0
- package/bin/video/find-video-mkv-issues.py +48 -65
- package/bin/video/fix-codecs.py +25 -30
- package/bin/video/largest-tv-shows.py +60 -57
- package/bin/video/remove-metadata.py +3 -6
- package/bin/video/rename-video-file.py +74 -84
- package/bin/video/scan-videos-audio-language.py +16 -33
- package/bin/video/show-codecs.py +20 -24
- package/bin/video/validate-video-files.py +5 -8
- package/bin/video/video-list.py +17 -20
- package/package.json +4 -10
- package/shell/che.bash +33 -33
- package/shell/che.fish +7 -7
- package/shell/che.zsh +33 -33
- package/shell/completions/_che +19 -19
- package/shell/completions/che.bash +15 -15
- package/shell/completions/che.fish +34 -50
package/bin/gitutils.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Git helpers for the scripts under ``bin/git/``.
|
|
2
|
+
|
|
3
|
+
Split out of ``utils.py``, which keeps the logging, argument parsing and
|
|
4
|
+
subprocess plumbing every script shares. Scripts pick it up the same way::
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
10
|
+
from gitutils import skip_git_hooks, worktree_branch_map
|
|
11
|
+
|
|
12
|
+
Standard library only, like everything else at the ``bin/`` root.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
from utils import info, run, run_output, warning
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def skip_git_hooks() -> None:
|
|
26
|
+
"""Stop every git this process runs, and every script it spawns, running hooks.
|
|
27
|
+
|
|
28
|
+
The branch scripts only check out, rebase and delete branches. A repo's
|
|
29
|
+
hooks (lefthook, husky) gate the user's own commits and pushes, yet
|
|
30
|
+
``git checkout`` fires post-checkout and ``git rebase`` fires pre-rebase,
|
|
31
|
+
prepare-commit-msg, post-commit and post-rewrite as it replays each
|
|
32
|
+
commit. A hooks path of /dev/null holds no hooks at all.
|
|
33
|
+
|
|
34
|
+
It is set through GIT_CONFIG_COUNT (git 2.31+) rather than ``-c`` on each
|
|
35
|
+
call so no git invocation can miss it and a delegated script inherits it.
|
|
36
|
+
Repeat calls are no-ops, so a script and the scripts it delegates to can
|
|
37
|
+
all call it.
|
|
38
|
+
"""
|
|
39
|
+
count = int(os.environ.get("GIT_CONFIG_COUNT") or 0)
|
|
40
|
+
for index in range(count):
|
|
41
|
+
if (
|
|
42
|
+
os.environ.get(f"GIT_CONFIG_KEY_{index}") == "core.hooksPath"
|
|
43
|
+
and os.environ.get(f"GIT_CONFIG_VALUE_{index}") == os.devnull
|
|
44
|
+
):
|
|
45
|
+
return
|
|
46
|
+
os.environ[f"GIT_CONFIG_KEY_{count}"] = "core.hooksPath"
|
|
47
|
+
os.environ[f"GIT_CONFIG_VALUE_{count}"] = os.devnull
|
|
48
|
+
os.environ["GIT_CONFIG_COUNT"] = str(count + 1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Set once the remotes are fetched, and inherited by every script delegated to
|
|
52
|
+
# after that, so a sync fetches once rather than once per step.
|
|
53
|
+
_FETCHED = "CHE_REMOTES_FETCHED"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fetch_remotes() -> None:
|
|
57
|
+
"""``git fetch --all --prune``, once per run however many scripts ask.
|
|
58
|
+
|
|
59
|
+
sync-all-branches fetches, then delegates to three scripts that each fetch
|
|
60
|
+
when run on their own. Like :func:`skip_git_hooks`, the marker lives in the
|
|
61
|
+
environment, so a delegated script inherits it and skips its own fetch.
|
|
62
|
+
"""
|
|
63
|
+
if os.environ.get(_FETCHED):
|
|
64
|
+
return
|
|
65
|
+
info("📡 Fetching + pruning remotes...")
|
|
66
|
+
# Not wrapped: the shell version ran this bare under `set -e`, so a fetch
|
|
67
|
+
# failure exited with git's own status (128), not a flattened 1.
|
|
68
|
+
run(["git", "fetch", "--all", "--prune", "--quiet"])
|
|
69
|
+
os.environ[_FETCHED] = "1"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Refs
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def current_branch(cwd: str | Path | None = None) -> str:
|
|
78
|
+
"""The branch checked out in ``cwd``'s worktree, or "" when HEAD is detached.
|
|
79
|
+
|
|
80
|
+
The full ref minus ``refs/heads/``, not ``--short``, which reads ``heads/x``
|
|
81
|
+
whenever a tag ``x`` exists (see :func:`local_branches`).
|
|
82
|
+
"""
|
|
83
|
+
ref = run_output(["git", "symbolic-ref", "--quiet", "HEAD"], check=False, cwd=cwd)
|
|
84
|
+
return ref.removeprefix("refs/heads/")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def has_tracked_changes(cwd: str | Path | None = None) -> bool:
|
|
88
|
+
"""True when ``cwd``'s worktree has uncommitted changes to tracked files.
|
|
89
|
+
|
|
90
|
+
Untracked files do not count: checkout and rebase leave them alone, and a
|
|
91
|
+
linked worktree nested inside the main one shows up as untracked.
|
|
92
|
+
"""
|
|
93
|
+
return bool(run_output(["git", "status", "--porcelain", "--untracked-files=no"], cwd=cwd))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def for_each_ref(
|
|
97
|
+
pattern: str, *fields: str, sort: str = "", cwd: str | Path | None = None
|
|
98
|
+
) -> list[list[str]]:
|
|
99
|
+
"""``git for-each-ref`` over ``pattern``, one list of ``fields`` per ref.
|
|
100
|
+
|
|
101
|
+
Each field is a format atom without its ``%(...)``, so
|
|
102
|
+
``for_each_ref("refs/remotes/origin", "refname:lstrip=3", "symref")``
|
|
103
|
+
yields ``[branch, symref]`` pairs. ``sort`` is passed as ``--sort``.
|
|
104
|
+
|
|
105
|
+
DELIBERATE DIVERGENCE: the shell versions joined fields with ``|``. A
|
|
106
|
+
branch name may legally contain a pipe, and so may an author email, so a
|
|
107
|
+
record like ``a|b[gone]`` split wrong - reporting an upstream a branch did
|
|
108
|
+
not have, or deleting branch ``a``. The fields are joined with \\x01
|
|
109
|
+
instead: control characters are illegal in ref names, so it cannot collide.
|
|
110
|
+
"""
|
|
111
|
+
argv = ["git", "for-each-ref", "--format=" + "%01".join(f"%({field})" for field in fields)]
|
|
112
|
+
if sort:
|
|
113
|
+
argv.append(f"--sort={sort}")
|
|
114
|
+
# Another divergence: the shell scripts read refs through `< <(...)`, whose
|
|
115
|
+
# failure does not trip `set -e`, so a failing for-each-ref looked like "no
|
|
116
|
+
# branches" and the script exited 0. Here a failure raises.
|
|
117
|
+
output = run_output([*argv, pattern], cwd=cwd)
|
|
118
|
+
return [line.split("\x01") for line in output.split("\n") if line]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def local_branches(*fields: str, sort: str = "", cwd: str | Path | None = None) -> list[list[str]]:
|
|
122
|
+
"""Every local branch as ``[name, *fields]``, through :func:`for_each_ref`.
|
|
123
|
+
|
|
124
|
+
The name is ``refname:lstrip=2``, not ``refname:short``: the short form
|
|
125
|
+
becomes ``heads/x`` whenever ``x`` is ambiguous (a tag named like the
|
|
126
|
+
branch, or a local branch called ``origin/x``), and ``git branch -D
|
|
127
|
+
heads/x`` then fails while ``git checkout heads/x`` detaches HEAD.
|
|
128
|
+
"""
|
|
129
|
+
return for_each_ref("refs/heads/", "refname:lstrip=2", *fields, sort=sort, cwd=cwd)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Worktrees
|
|
134
|
+
#
|
|
135
|
+
# A branch checked out in a linked worktree cannot be checked out, deleted or
|
|
136
|
+
# reset from the main clone - git refuses with "already used by worktree".
|
|
137
|
+
# That is a mechanical limit, not a conflict, so every script that touches
|
|
138
|
+
# branches needs to know which ones are pinned and where.
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Worktree(NamedTuple):
|
|
143
|
+
path: str
|
|
144
|
+
branch: str # "" when detached (or bare): it pins no branch
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def worktrees(cwd: str | Path | None = None) -> list[Worktree]:
|
|
148
|
+
"""Every worktree, in ``git worktree list --porcelain`` order.
|
|
149
|
+
|
|
150
|
+
The first entry is always the main worktree; callers drop it themselves so
|
|
151
|
+
the distinction stays explicit at the call site.
|
|
152
|
+
|
|
153
|
+
DELIBERATE DIVERGENCE: the shell version split on whitespace. NUL-delimited
|
|
154
|
+
porcelain preserves spaces, tabs, backslashes and newlines in paths without
|
|
155
|
+
Git's quoted-path escaping, so commands can route to the exact checkout.
|
|
156
|
+
"""
|
|
157
|
+
found: list[Worktree] = []
|
|
158
|
+
for line in run_output(["git", "worktree", "list", "--porcelain", "-z"], cwd=cwd).split("\0"):
|
|
159
|
+
if line.startswith("worktree "):
|
|
160
|
+
found.append(Worktree(line.removeprefix("worktree "), ""))
|
|
161
|
+
elif line.startswith("branch refs/heads/"):
|
|
162
|
+
# Every record opens with its `worktree` line, so this belongs to
|
|
163
|
+
# the last one appended.
|
|
164
|
+
found[-1] = found[-1]._replace(branch=line.removeprefix("branch refs/heads/"))
|
|
165
|
+
return found
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def worktree_branch_map(cwd: str | Path | None = None) -> dict[str, str]:
|
|
169
|
+
"""Map ``branch -> worktree path`` for every worktree with a branch checked out.
|
|
170
|
+
|
|
171
|
+
Detached worktrees are skipped (they pin no branch). The main worktree IS
|
|
172
|
+
included, because its branch is pinned for exactly the same reason.
|
|
173
|
+
|
|
174
|
+
DELIBERATE DIVERGENCE: ``utils.sh`` emitted "<branch>\\t<path>" lines that
|
|
175
|
+
every caller re-parsed. A dict is the same data without the round trip, and
|
|
176
|
+
it cannot be truncated by a path containing a tab.
|
|
177
|
+
"""
|
|
178
|
+
mapping: dict[str, str] = {}
|
|
179
|
+
for worktree in worktrees(cwd):
|
|
180
|
+
if worktree.branch:
|
|
181
|
+
# First writer wins: a branch can only be pinned by one worktree,
|
|
182
|
+
# so a duplicate would mean git contradicted itself.
|
|
183
|
+
mapping.setdefault(worktree.branch, worktree.path)
|
|
184
|
+
return mapping
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def worktree_is_clean(path: str | Path) -> bool:
|
|
188
|
+
"""True when the worktree at ``path`` has no modified or untracked files.
|
|
189
|
+
|
|
190
|
+
Ignored build output (node_modules, .next) does not count as dirty, because
|
|
191
|
+
``git status --porcelain`` does not list ignored paths.
|
|
192
|
+
"""
|
|
193
|
+
completed = run(["git", "-C", str(path), "status", "--porcelain"], check=False, capture=True)
|
|
194
|
+
# A non-zero exit means the path is not a working tree at all. Treating
|
|
195
|
+
# that as "clean" would let a caller delete a directory it never inspected.
|
|
196
|
+
if completed.returncode != 0:
|
|
197
|
+
return False
|
|
198
|
+
return not (completed.stdout or "").strip()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def find_main_branch(cwd: str | Path | None = None) -> str:
|
|
202
|
+
"""The local main branch, preferring ``main`` over ``master``, or "" for neither.
|
|
203
|
+
|
|
204
|
+
Takes an explicit ``cwd`` because callers run it after moving to the main
|
|
205
|
+
worktree; relying on the process working directory would make the answer
|
|
206
|
+
depend on where the script happened to be invoked from.
|
|
207
|
+
"""
|
|
208
|
+
for candidate in ("main", "master"):
|
|
209
|
+
exists = run(
|
|
210
|
+
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{candidate}"],
|
|
211
|
+
check=False,
|
|
212
|
+
capture=True,
|
|
213
|
+
cwd=cwd,
|
|
214
|
+
)
|
|
215
|
+
if exists.returncode == 0:
|
|
216
|
+
return candidate
|
|
217
|
+
return ""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def park_main_worktree(action: str, *, dry_run: bool) -> Path:
|
|
221
|
+
"""Put the main worktree on main (or master) before ``action``; return its path.
|
|
222
|
+
|
|
223
|
+
The main worktree, not the one the script was run from: removing a worktree
|
|
224
|
+
fails while standing in it, and the branch scripts expect the main clone.
|
|
225
|
+
Starting from a known-good branch keeps every later step predictable.
|
|
226
|
+
|
|
227
|
+
Exits 1 outside a working tree, when there is neither a ``main`` nor a
|
|
228
|
+
``master``, or when the main worktree has uncommitted changes to tracked
|
|
229
|
+
files (see :func:`has_tracked_changes`). ``dry_run`` reports the switch
|
|
230
|
+
instead of making it.
|
|
231
|
+
"""
|
|
232
|
+
# "true" on stdout, not just exit 0: inside a bare repo or a .git directory
|
|
233
|
+
# rev-parse succeeds and prints "false".
|
|
234
|
+
if run_output(["git", "rev-parse", "--is-inside-work-tree"], check=False) != "true":
|
|
235
|
+
warning("❌ Not inside a git working tree.")
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
|
|
238
|
+
main_wt = Path(worktrees()[0].path)
|
|
239
|
+
|
|
240
|
+
main_branch = find_main_branch(main_wt)
|
|
241
|
+
if not main_branch:
|
|
242
|
+
warning(f"❌ Neither 'main' nor 'master' exists locally — refusing to {action}.")
|
|
243
|
+
sys.exit(1)
|
|
244
|
+
|
|
245
|
+
current = current_branch(main_wt)
|
|
246
|
+
if current == main_branch:
|
|
247
|
+
info(f"📍 Main worktree already on 🌿 {main_branch}")
|
|
248
|
+
return main_wt
|
|
249
|
+
|
|
250
|
+
if has_tracked_changes(main_wt):
|
|
251
|
+
warning(
|
|
252
|
+
f"❌ Main worktree has uncommitted changes — commit or stash before you {action}. 🧺"
|
|
253
|
+
)
|
|
254
|
+
sys.exit(1)
|
|
255
|
+
|
|
256
|
+
if dry_run:
|
|
257
|
+
info(
|
|
258
|
+
f"🌵 Would switch main worktree to 🌿 {main_branch} (currently on {current or 'detached'})"
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
info(f"🔀 Switching main worktree to 🌿 {main_branch} (from {current or 'detached'})")
|
|
262
|
+
run(["git", "checkout", "--quiet", main_branch], cwd=main_wt)
|
|
263
|
+
return main_wt
|