@codebend3r/che 1.1.0 โ†’ 1.2.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 (53) hide show
  1. package/README.md +38 -24
  2. package/bin/art.py +22 -20
  3. package/bin/che.py +208 -587
  4. package/bin/commands.py +220 -231
  5. package/bin/doctor.py +220 -0
  6. package/bin/drives/eject-all-drives.py +21 -38
  7. package/bin/drives/mount-all-drives.py +18 -62
  8. package/bin/drives/ping-nas.py +77 -105
  9. package/bin/files/compress-folders.py +7 -11
  10. package/bin/files/delete-by-ext.py +10 -14
  11. package/bin/files/delete-empty-folders.py +33 -37
  12. package/bin/files/delete-smb-files.py +12 -21
  13. package/bin/files/files-under-size.py +5 -11
  14. package/bin/files/find-largest-files.py +21 -25
  15. package/bin/files/list-permission.py +40 -0
  16. package/bin/files/make-alpha-dir.py +2 -0
  17. package/bin/git/all-actions.py +350 -253
  18. package/bin/git/checkout-my-branches.py +42 -63
  19. package/bin/git/clean-stale-branches.py +23 -48
  20. package/bin/git/prune-worktrees.py +12 -51
  21. package/bin/git/sync-all-branches.py +54 -283
  22. package/bin/git/update-local-branches.py +67 -73
  23. package/bin/gitutils.py +263 -0
  24. package/bin/install.py +393 -1182
  25. package/bin/machine.py +345 -0
  26. package/bin/media.py +127 -0
  27. package/bin/menu.py +437 -0
  28. package/bin/nas.py +102 -0
  29. package/bin/rcfile.py +380 -0
  30. package/bin/shellgen.py +503 -431
  31. package/bin/system/btop-launch.py +17 -13
  32. package/bin/system/update-brew.py +57 -68
  33. package/bin/tui.py +156 -153
  34. package/bin/utils.py +165 -237
  35. package/bin/video/delete-duplicate-videos.py +64 -62
  36. package/bin/video/detect-green-magenta-videos.py +105 -95
  37. package/bin/video/find-movie-by-year.py +3 -0
  38. package/bin/video/find-video-mkv-issues.py +48 -65
  39. package/bin/video/fix-codecs.py +25 -30
  40. package/bin/video/largest-tv-shows.py +60 -57
  41. package/bin/video/remove-metadata.py +3 -6
  42. package/bin/video/rename-video-file.py +74 -84
  43. package/bin/video/scan-videos-audio-language.py +16 -33
  44. package/bin/video/show-codecs.py +20 -24
  45. package/bin/video/validate-video-files.py +5 -8
  46. package/bin/video/video-list.py +17 -20
  47. package/package.json +4 -10
  48. package/shell/che.bash +33 -33
  49. package/shell/che.fish +7 -7
  50. package/shell/che.zsh +33 -33
  51. package/shell/completions/_che +19 -19
  52. package/shell/completions/che.bash +15 -15
  53. package/shell/completions/che.fish +34 -50
@@ -1,34 +1,25 @@
1
1
  #!/usr/bin/env python3
2
- """๐Ÿ”„ Sync everything: push every worktree, collapse the pushed ones, then tidy branches.
3
-
4
- A worktree is a temporary workspace. Once its commits are on origin, the
5
- directory holds nothing the remote does not, so it is removed and the branch
6
- stays behind as a plain local branch that the later steps can update normally.
7
- That also clears the "already used by worktree" pin, which is what otherwise
8
- makes main un-checkoutable and stale branches un-deletable.
2
+ """๐Ÿ”„ Sync every branch: delete merged ones, rebase the rest, check out new ones.
9
3
 
10
4
  Steps, in order:
11
- 1. git fetch --all --prune
12
- 2. Push every linked worktree's branch to origin
13
- 3. Remove each pushed + clean worktree, keeping its branch
14
- 4. Prune stale worktree admin records
15
- 5. Switch the main clone to the main branch
16
- 6. clean-stale-branches (delete branches whose upstream is gone)
17
- 7. update-local-branches (rebase every branch onto its upstream)
18
- 8. checkout-my-branches (check out your remote branches that aren't local)
19
-
20
- A worktree is only collapsed when it is clean AND fully pushed. Anything else
21
- is kept and reported with the reason.
5
+ 1. Switch the main clone to the main branch
6
+ 2. Fetch + prune every remote, once for the whole run
7
+ 3. clean-stale-branches (delete branches whose upstream is gone)
8
+ 4. update-local-branches (rebase every branch onto its upstream)
9
+ 5. checkout-my-branches (track your remote branches that aren't local)
10
+
11
+ Only branches change. It never pushes, never adds, removes or prunes a
12
+ worktree, and never runs a repo's git hooks: pushing and worktrees are the
13
+ user's call, and a lefthook or husky pre-commit or pre-push gates the user's
14
+ own work, not this bookkeeping. A branch checked out in a linked worktree is
15
+ rebased in place there; a stale one is reported, not deleted. The main clone
16
+ ends on main: new branches are created without being checked out.
22
17
 
23
18
  Usage:
24
- sync-all-branches.py [--dry-run] [--no-push] [--keep-locked]
25
- [--author=EMAIL] [--limit=N]
19
+ sync-all-branches.py [--dry-run] [--author=EMAIL] [--limit=N]
26
20
 
27
21
  Options:
28
- --dry-run Report what would be pushed / removed / synced, change nothing
29
- --no-push Never push; only collapse worktrees that are already pushed
30
- --keep-locked Leave locked worktrees alone (default: release the lock once
31
- the worktree is pushed and clean)
22
+ --dry-run Report what would be deleted / synced, change nothing
32
23
  --author=EMAIL Passed through to checkout-my-branches
33
24
  --limit=N Passed through to checkout-my-branches
34
25
  --help Show help
@@ -39,194 +30,60 @@ Env:
39
30
 
40
31
  from __future__ import annotations
41
32
 
33
+ import argparse
42
34
  import sys
43
35
  from pathlib import Path
44
36
 
45
37
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
46
38
 
39
+ from gitutils import fetch_remotes, park_main_worktree, skip_git_hooks
47
40
  from utils import (
48
41
  add_bool_flag,
49
- branch_is_pushed,
50
42
  build_parser,
51
43
  env_bool,
52
- find_main_branch,
44
+ heading,
53
45
  info,
54
46
  log,
55
- main_worktree,
56
- note,
57
47
  require_binary,
58
48
  run,
59
49
  run_cli,
60
- run_output,
61
- success,
62
- warning,
63
- worktree_branch_map,
64
- worktree_is_clean,
65
50
  )
66
51
 
67
- __version__ = "1.0.0"
52
+ __version__ = "2.1.0"
68
53
 
69
54
  SCRIPT_DIR = Path(__file__).resolve().parent
70
55
 
71
56
 
72
- def delegate(script: str, args: list[str], *, cwd: Path, dry_run: bool) -> None:
57
+ def delegate(script: str, args: list[str], *, cwd: Path) -> None:
73
58
  """Run a sibling script in the main worktree, aborting the sync if it fails.
74
59
 
75
60
  The shell version invoked these under ``set -e``, so a non-zero exit from
76
61
  any of them ended the run. ``check=True`` reproduces that: a branch that
77
62
  could not be rebased is a reason to stop and look, not to sail on.
78
63
 
79
- DRY_RUN is passed explicitly rather than inherited, so the child can never
80
- disagree with the parent about which mode this is.
81
- """
82
- run(
83
- [sys.executable, str(SCRIPT_DIR / script), *args],
84
- cwd=cwd,
85
- env={"DRY_RUN": "true" if dry_run else "false"},
86
- )
87
-
88
-
89
- def collapse_worktrees(
90
- *,
91
- main_wt: Path,
92
- push: bool,
93
- keep_locked: bool,
94
- dry_run: bool,
95
- ) -> tuple[list[str], list[str], list[str]]:
96
- """Push, then remove, every linked worktree that is clean and fully pushed.
97
-
98
- Returns ``(pushed, collapsed, kept)`` for the summary. ``kept`` entries
99
- carry the reason, because "nothing happened" is the one outcome a user
100
- needs explained.
64
+ The blank line before each one is flushed: a child writes straight to the
65
+ shared stdout, so an unflushed separator would surface after its output
66
+ whenever the sync is piped.
101
67
  """
102
- pushed: list[str] = []
103
- collapsed: list[str] = []
104
- kept: list[str] = []
105
-
106
- info("๐ŸŒฟ Inspecting linked worktrees...")
107
-
108
- # Snapshot the map first: removing a worktree mutates the list git reports.
109
- worktrees = [
110
- (branch, path)
111
- for branch, path in worktree_branch_map(cwd=main_wt).items()
112
- if path and Path(path) != main_wt
113
- ]
114
-
115
- if not worktrees:
116
- info(" โ„น๏ธ No linked worktrees with a branch checked out.") # noqa: RUF001
117
-
118
- for branch, path in worktrees:
119
- # A dirty worktree holds work that only exists there. Never touch it.
120
- if not worktree_is_clean(path):
121
- kept.append(f"{branch} ({path}) โ€” dirty")
122
- warning(f" โญ๏ธ {branch} โ€” dirty, keeping {path}")
123
- continue
124
-
125
- # Push whatever is not on origin yet, so the directory becomes redundant.
126
- if not branch_is_pushed(branch, cwd=main_wt):
127
- if not push:
128
- kept.append(f"{branch} ({path}) โ€” unpushed, --no-push")
129
- warning(f" โญ๏ธ {branch} โ€” unpushed and --no-push, keeping {path}")
130
- continue
131
-
132
- if dry_run:
133
- info(f" ๐ŸŒต Would push: {branch} โ†’ origin")
134
- pushed.append(branch)
135
- else:
136
- log(f" โฌ†๏ธ Pushing {branch} โ†’ origin")
137
- result = run(
138
- ["git", "-C", path, "push", "--quiet", "--set-upstream", "origin", branch],
139
- check=False,
140
- )
141
- if result.returncode != 0:
142
- kept.append(f"{branch} ({path}) โ€” push failed")
143
- warning(f" โŒ push failed for {branch}, keeping {path}")
144
- continue
145
- pushed.append(branch)
146
-
147
- # Re-check rather than assume: a push can succeed and still leave the
148
- # branch behind its upstream if someone else pushed in between.
149
- if not dry_run and not branch_is_pushed(branch, cwd=main_wt):
150
- kept.append(f"{branch} ({path}) โ€” still not fully pushed")
151
- warning(f" โญ๏ธ {branch} โ€” still not fully pushed, keeping {path}")
152
- continue
153
-
154
- if dry_run:
155
- info(f" ๐ŸŒต Would collapse: {path} (branch {branch} kept)")
156
- collapsed.append(f"{branch} ({path})")
157
- continue
158
-
159
- # A lock is metadata stamped by whatever created the worktree (a tool,
160
- # an agent, the user), and `git worktree remove` refuses while one is
161
- # set. The lock says nothing about unsaved work โ€” the clean and pushed
162
- # gates above are what protect that โ€” so release it rather than
163
- # skipping. This is NOT --force, which is the flag that discards work.
164
- if not keep_locked:
165
- run(["git", "worktree", "unlock", path], check=False, capture=True, cwd=main_wt)
166
-
167
- removed = run(["git", "worktree", "remove", path], check=False, cwd=main_wt)
168
- if removed.returncode == 0:
169
- success(f" โœ… Collapsed {path} โ€” branch {branch} kept")
170
- collapsed.append(f"{branch} ({path})")
171
- else:
172
- kept.append(f"{branch} ({path}) โ€” removal failed (locked?)")
173
- warning(f" โŒ Could not remove {path}")
174
-
175
- return pushed, collapsed, kept
176
-
177
-
178
- def report(
179
- *,
180
- pushed: list[str],
181
- collapsed: list[str],
182
- kept: list[str],
183
- started_in: Path,
184
- main_wt: Path,
185
- ) -> None:
186
- """Print the worktree summary."""
187
- print()
188
- note("โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€")
189
- success("โ•โ•โ• worktree summary โ•โ•โ•")
190
- success(f"โฌ†๏ธ Pushed: {len(pushed)}")
191
- for branch in pushed:
192
- success(f" โ€ข ๐ŸŒฟ {branch}")
193
- success(f"๐Ÿ“ฆ Collapsed: {len(collapsed)}")
194
- for branch in collapsed:
195
- success(f" โ€ข ๐Ÿชฆ {branch}")
196
- if kept:
197
- warning(f"โญ๏ธ Kept: {len(kept)}")
198
- for branch in kept:
199
- warning(f" โ€ข ๐ŸŒฟ {branch}")
200
-
201
- if started_in != main_wt:
202
- info(f"๐Ÿ“ Started in {started_in}; finished in {main_wt}.")
203
- if not started_in.is_dir():
204
- warning(f"โš ๏ธ {started_in} was collapsed โ€” your shell is in a stale directory.")
205
- warning(f" Run: cd {main_wt}")
68
+ print(flush=True)
69
+ run([sys.executable, str(SCRIPT_DIR / script), *args], cwd=cwd)
206
70
 
207
71
 
208
72
  def main(argv: list[str] | None = None) -> int:
209
73
  parser = build_parser(
210
74
  prog="sync-all-branches",
211
- description="๐Ÿ”„ Push every worktree, collapse the pushed ones, then tidy branches.",
75
+ description="๐Ÿ”„ Delete merged branches, rebase the rest, check out new ones.",
212
76
  )
213
77
  add_bool_flag(
214
78
  parser,
215
79
  "--dry-run",
216
80
  default=env_bool("DRY_RUN", False),
217
- help="Report what would be pushed / removed / synced, change nothing",
218
- )
219
- add_bool_flag(
220
- parser,
221
- "--no-push",
222
- allow_value=False,
223
- help="Never push; only collapse worktrees that are already pushed",
224
- )
225
- add_bool_flag(
226
- parser,
227
- "--keep-locked",
228
- help="Leave locked worktrees alone (default: release the lock once pushed and clean)",
81
+ help="Report what would be deleted / synced, change nothing",
229
82
  )
83
+ # Accepted and ignored, from when the sync pushed and collapsed worktrees:
84
+ # chip's "Update from Origin" still passes --no-push and must keep running.
85
+ add_bool_flag(parser, "--no-push", allow_value=False, help=argparse.SUPPRESS)
86
+ add_bool_flag(parser, "--keep-locked", help=argparse.SUPPRESS)
230
87
  parser.add_argument(
231
88
  "--author",
232
89
  default="",
@@ -242,123 +99,37 @@ def main(argv: list[str] | None = None) -> int:
242
99
  args = parser.parse_args(argv)
243
100
 
244
101
  require_binary("git")
102
+ skip_git_hooks()
245
103
 
246
- inside = run(["git", "rev-parse", "--is-inside-work-tree"], check=False, capture=True)
247
- if inside.returncode != 0:
248
- warning("โŒ Not inside a git working tree.")
249
- return 1
250
-
251
- header_suffix = " ๐ŸŒต (dry run)" if args.dry_run else ""
252
- note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
253
- note(f"๐Ÿ”„ sync-all-branches{header_suffix}")
254
- note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
255
-
256
- # Every later step runs from the main worktree: a worktree cannot be
257
- # removed while we are standing inside it, and the delegated scripts
258
- # expect the main clone.
259
- main_wt_raw = main_worktree()
260
- if not main_wt_raw:
261
- warning("โŒ Could not determine main worktree.")
262
- return 1
263
-
264
- main_wt = Path(main_wt_raw)
265
- # The shell version `cd`-ed here. Passing cwd= to each call instead keeps
266
- # the process's own directory untouched, so a caller that sourced this in
267
- # the same shell would not be moved out from under itself.
268
- started_in = Path.cwd().resolve()
104
+ heading("๐Ÿ”„ sync-all-branches", dry_run=args.dry_run)
269
105
 
270
- main_branch = find_main_branch(main_wt)
271
- if not main_branch:
272
- warning("โŒ Neither 'main' nor 'master' exists locally โ€” refusing to sync.")
273
- return 1
106
+ # Every later step runs from the main worktree, because the delegated
107
+ # scripts expect the main clone. Passing cwd= to each call rather than
108
+ # `cd`-ing keeps the process's own directory untouched.
109
+ main_wt = park_main_worktree("sync", dry_run=args.dry_run)
274
110
 
275
- # -----------------------------------------------------------------------
276
- # Step 1 โ€” fetch + prune
277
- # -----------------------------------------------------------------------
111
+ # The one fetch of the run. Each delegated script fetches when run on its
112
+ # own; here they inherit the marker fetch_remotes leaves and skip theirs.
113
+ fetch_remotes()
278
114
 
279
- info("๐Ÿ“ก Fetching + pruning remotes...")
280
- if args.dry_run:
281
- info("๐ŸŒต Would run: git fetch --all --prune")
282
- else:
283
- run(["git", "fetch", "--all", "--prune", "--quiet"], cwd=main_wt)
284
-
285
- # -----------------------------------------------------------------------
286
- # Steps 2-3 โ€” push every worktree, then collapse the pushed ones
287
- # -----------------------------------------------------------------------
115
+ # Dry-run travels as an explicit --dry-run=true|false, never as DRY_RUN in
116
+ # the environment, so a child can never disagree with the parent about
117
+ # which mode this is - whatever DRY_RUN it inherited.
118
+ mode = [f"--dry-run={'true' if args.dry_run else 'false'}"]
119
+ delegate("clean-stale-branches.py", mode, cwd=main_wt)
120
+ delegate("update-local-branches.py", mode, cwd=main_wt)
288
121
 
289
- pushed, collapsed, kept = collapse_worktrees(
290
- main_wt=main_wt,
291
- push=not args.no_push,
292
- keep_locked=args.keep_locked,
293
- dry_run=args.dry_run,
294
- )
295
-
296
- # -----------------------------------------------------------------------
297
- # Step 4 โ€” prune stale admin records
298
- # -----------------------------------------------------------------------
122
+ checkout_args = []
123
+ if args.author:
124
+ checkout_args.append(f"--author={args.author}")
125
+ if args.limit:
126
+ checkout_args.append(f"--limit={args.limit}")
299
127
 
300
128
  if args.dry_run:
301
- info("๐ŸŒต Would run: git worktree prune")
129
+ print(flush=True)
130
+ info(f"๐ŸŒต Would run: checkout-my-branches {' '.join(checkout_args)}".rstrip())
302
131
  else:
303
- run(["git", "worktree", "prune"], cwd=main_wt)
304
-
305
- # -----------------------------------------------------------------------
306
- # Step 5 โ€” park on main before anything tries to delete or rebase branches
307
- # -----------------------------------------------------------------------
308
-
309
- current = run_output(
310
- ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], check=False, cwd=main_wt
311
- ).strip()
312
-
313
- if current != main_branch:
314
- # Only tracked changes block: untracked files (including worktrees
315
- # nested under the main worktree) are not touched by checkout.
316
- if run_output(["git", "status", "--porcelain", "--untracked-files=no"], cwd=main_wt):
317
- warning("โŒ Main worktree has uncommitted changes โ€” commit or stash first. ๐Ÿงบ")
318
- return 1
319
-
320
- if args.dry_run:
321
- info(f"๐ŸŒต Would switch to ๐ŸŒฟ {main_branch} (currently on {current or 'detached'})")
322
- else:
323
- info(f"๐Ÿ”€ Switching to ๐ŸŒฟ {main_branch} (from {current or 'detached'})")
324
- run(["git", "checkout", "--quiet", main_branch], cwd=main_wt)
325
- else:
326
- info(f"๐Ÿ“ Already on ๐ŸŒฟ {main_branch}")
327
-
328
- # -----------------------------------------------------------------------
329
- # Steps 6-8 โ€” delegate to the single-purpose scripts
330
- # -----------------------------------------------------------------------
331
-
332
- delegate_args = ["--dry-run"] if args.dry_run else []
333
-
334
- # DELIBERATE DIVERGENCE: the summary is printed even when a delegate fails.
335
- # The pushes and collapses above already happened; aborting without saying
336
- # which ones would leave the user guessing what state the repo is in.
337
- try:
338
- print()
339
- delegate("clean-stale-branches.py", delegate_args, cwd=main_wt, dry_run=args.dry_run)
340
-
341
- print()
342
- delegate("update-local-branches.py", delegate_args, cwd=main_wt, dry_run=args.dry_run)
343
-
344
- checkout_args = []
345
- if args.author:
346
- checkout_args.append(f"--author={args.author}")
347
- if args.limit:
348
- checkout_args.append(f"--limit={args.limit}")
349
-
350
- print()
351
- if args.dry_run:
352
- info(f"๐ŸŒต Would run: checkout-my-branches {' '.join(checkout_args)}".rstrip())
353
- else:
354
- delegate("checkout-my-branches.py", checkout_args, cwd=main_wt, dry_run=args.dry_run)
355
- except BaseException:
356
- report(
357
- pushed=pushed, collapsed=collapsed, kept=kept, started_in=started_in, main_wt=main_wt
358
- )
359
- raise
360
-
361
- report(pushed=pushed, collapsed=collapsed, kept=kept, started_in=started_in, main_wt=main_wt)
132
+ delegate("checkout-my-branches.py", checkout_args, cwd=main_wt)
362
133
 
363
134
  log("๐ŸŽ‰ Sync complete.")
364
135
  return 0
@@ -14,56 +14,57 @@ Options:
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- import re
18
17
  import sys
19
18
  from pathlib import Path
20
19
 
21
20
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22
21
 
22
+ from gitutils import (
23
+ current_branch,
24
+ fetch_remotes,
25
+ has_tracked_changes,
26
+ local_branches,
27
+ skip_git_hooks,
28
+ worktree_branch_map,
29
+ worktree_is_clean,
30
+ )
23
31
  from utils import (
24
32
  add_bool_flag,
25
33
  build_parser,
34
+ heading,
26
35
  info,
27
36
  log,
28
- note,
37
+ parse_count,
29
38
  require_binary,
30
39
  run,
31
40
  run_cli,
32
- run_lines,
33
- run_output,
34
41
  success,
35
42
  warning,
36
- worktree_branch_map,
37
- worktree_is_clean,
38
43
  )
39
44
 
40
- __version__ = "3.1.0"
45
+ __version__ = "3.2.0"
41
46
 
42
47
 
43
48
  def branches_with_upstream() -> list[str]:
44
- """Local branches that have an upstream, most recently committed first.
45
-
46
- The separator is \\x01, not the shell version's ``|``. A branch name may
47
- legally contain a pipe (``git check-ref-format`` allows it), and splitting
48
- on ``|`` then treating the whole remainder as the upstream reports such a
49
- branch as *having* an upstream when it does not - after which
50
- ``git pull --rebase`` fails on it and the run exits non-zero. Control
51
- characters are illegal in ref names, so \\x01 cannot collide.
49
+ """Local branches that have an upstream, most recently committed first."""
50
+ return [
51
+ branch for branch, upstream in local_branches("upstream", sort="-committerdate") if upstream
52
+ ]
53
+
54
+
55
+ def rebase(git: list[str]) -> bool:
56
+ """Rebase onto the upstream with ``git`` (plain, or ``-C <worktree>``); abort on failure.
57
+
58
+ DELIBERATE DIVERGENCE: the shell version ran ``pull --rebase`` per branch,
59
+ a fetch for every branch on top of the one the run opened with. The remotes
60
+ are fetched once up front, so a bare ``rebase`` (onto ``@{upstream}``, with
61
+ the same fork-point handling ``pull --rebase`` uses) is the whole job.
52
62
  """
53
- found = []
54
- for line in run_lines(
55
- [
56
- "git",
57
- "for-each-ref",
58
- "--sort=-committerdate",
59
- "--format=%(refname:short)%01%(upstream)",
60
- "refs/heads/",
61
- ]
62
- ):
63
- branch, _, upstream = line.partition("\x01")
64
- if branch and upstream:
65
- found.append(branch)
66
- return found
63
+ if run([*git, "rebase", "--quiet"], check=False).returncode == 0:
64
+ return True
65
+ warning(" โŒ rebase failed โ€” aborting and moving on")
66
+ run([*git, "rebase", "--abort"], check=False, capture=True)
67
+ return False
67
68
 
68
69
 
69
70
  def main(argv: list[str] | None = None) -> int:
@@ -71,8 +72,6 @@ def main(argv: list[str] | None = None) -> int:
71
72
  prog="update-local-branches",
72
73
  description="โฌ‡๏ธ Rebase every local branch that has an upstream onto origin.",
73
74
  )
74
- # Kept as a string, not `type=int`: argparse's own int conversion would
75
- # own the failure and exit before the shell version's message could print.
76
75
  parser.add_argument(
77
76
  "--limit",
78
77
  default="0",
@@ -86,31 +85,28 @@ def main(argv: list[str] | None = None) -> int:
86
85
  )
87
86
  args = parser.parse_args(argv)
88
87
 
89
- if not re.fullmatch(r"[0-9]+", args.limit):
90
- warning(f"โŒ --limit must be a non-negative integer (got: {args.limit})")
91
- return 1
92
- limit = int(args.limit)
88
+ limit = parse_count(args.limit, "--limit")
93
89
 
94
90
  require_binary("git")
91
+ skip_git_hooks()
95
92
 
96
- header_suffix = " ๐ŸŒต (dry run)" if args.dry_run else ""
97
- note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
98
- note(f"โฌ‡๏ธ update-local-branches{header_suffix}")
99
- note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
93
+ heading("โฌ‡๏ธ update-local-branches", dry_run=args.dry_run)
100
94
 
101
- original_branch = run_output(
102
- ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], check=False
103
- ).strip()
95
+ original_branch = current_branch()
104
96
  if not original_branch:
105
97
  warning("โŒ Detached HEAD โ€” check out a branch first.")
106
98
  return 1
107
99
 
108
- if not args.dry_run and run_output(["git", "status", "--porcelain"]):
100
+ # Tracked changes only: an untracked file survives checkout and rebase, and
101
+ # a linked worktree nested in this one is untracked - counting it made
102
+ # sync-all-branches abort right after parking had allowed the same tree.
103
+ if not args.dry_run and has_tracked_changes():
109
104
  warning("โŒ Working tree is dirty. Commit or stash before running. ๐Ÿงบ")
110
105
  return 1
111
106
 
112
- info("๐Ÿ“ก Fetching remotes...")
113
- run(["git", "fetch", "--all", "--quiet"])
107
+ # Prunes too, where this script's own fetch did not: it shares the one
108
+ # fetch sync-all-branches runs.
109
+ fetch_remotes()
114
110
 
115
111
  branches = branches_with_upstream()
116
112
  if limit > 0:
@@ -127,52 +123,46 @@ def main(argv: list[str] | None = None) -> int:
127
123
  return 0
128
124
 
129
125
  succeeded: list[str] = []
126
+ skipped: list[str] = []
130
127
  failed: list[str] = []
131
128
 
132
- # One `git worktree list` for the whole run, not one per branch as the
133
- # shell's per-branch `worktree_for_branch` did.
134
- pinned_by_branch = worktree_branch_map()
135
- here = Path.cwd().resolve()
129
+ # Branches checked out in ANOTHER worktree: every pinned one but the branch
130
+ # this worktree has checked out. One `git worktree list` for the whole run,
131
+ # not one per branch as the shell's per-branch `worktree_for_branch` did.
132
+ elsewhere = worktree_branch_map()
133
+ elsewhere.pop(original_branch, None)
136
134
 
137
135
  for branch in branches:
138
136
  # A branch checked out in another worktree cannot be checked out here โ€”
139
137
  # git refuses with "already used by worktree". That's a mechanical
140
138
  # limit, not a conflict, so update it in place rather than counting it
141
139
  # as a failure.
142
- pinned = pinned_by_branch.get(branch, "")
143
-
144
- if pinned and Path(pinned).resolve() != here:
140
+ if pinned := elsewhere.get(branch):
141
+ # Uncommitted work in a worktree is the user's, left exactly as it
142
+ # is. Skipping it is not a failure, or one worktree in progress
143
+ # would stop every run of sync-all-branches at this step.
145
144
  if not worktree_is_clean(pinned):
146
145
  warning(f" โญ๏ธ {branch} โ€” worktree is dirty ({pinned}), skipping")
147
- failed.append(branch)
146
+ skipped.append(branch)
148
147
  continue
149
148
 
150
149
  log(f"โฌ‡๏ธ Rebasing {branch} in its worktree ({pinned})")
151
- rebase = run(["git", "-C", pinned, "pull", "--rebase", "--quiet"], check=False)
152
- if rebase.returncode == 0:
153
- success(f" โœ… {branch} up to date (in {pinned})")
154
- succeeded.append(branch)
155
- else:
156
- warning(" โŒ rebase failed โ€” aborting and moving on")
157
- run(["git", "-C", pinned, "rebase", "--abort"], check=False, capture=True)
150
+ git, where = ["git", "-C", pinned], f" (in {pinned})"
151
+ else:
152
+ info(f"๐Ÿ“‚ Switching to ๐ŸŒฟ {branch}")
153
+ checkout = run(["git", "checkout", "--quiet", branch], check=False, capture=True)
154
+ if checkout.returncode != 0:
155
+ warning(" โœ— checkout failed, skipping")
158
156
  failed.append(branch)
159
- continue
157
+ continue
160
158
 
161
- info(f"๐Ÿ“‚ Switching to ๐ŸŒฟ {branch}")
162
- checkout = run(["git", "checkout", "--quiet", branch], check=False, capture=True)
163
- if checkout.returncode != 0:
164
- warning(" โœ— checkout failed, skipping")
165
- failed.append(branch)
166
- continue
159
+ log(f"โฌ‡๏ธ Rebasing {branch} onto upstream")
160
+ git, where = ["git"], ""
167
161
 
168
- log(f"โฌ‡๏ธ Rebasing {branch} onto upstream")
169
- pull = run(["git", "pull", "--rebase", "--quiet"], check=False)
170
- if pull.returncode == 0:
171
- success(f" โœ… {branch} up to date")
162
+ if rebase(git):
163
+ success(f" โœ… {branch} up to date{where}")
172
164
  succeeded.append(branch)
173
165
  else:
174
- warning(" โŒ rebase failed โ€” aborting and moving on")
175
- run(["git", "rebase", "--abort"], check=False, capture=True)
176
166
  failed.append(branch)
177
167
 
178
168
  info(f"๐Ÿ” Returning to ๐ŸŒฟ {original_branch}")
@@ -181,6 +171,10 @@ def main(argv: list[str] | None = None) -> int:
181
171
  print()
182
172
  success("โ•โ•โ• summary โ•โ•โ•")
183
173
  success(f"โœ… Updated: {len(succeeded)}")
174
+ if skipped:
175
+ warning(f"โญ๏ธ Skipped: {len(skipped)} (dirty worktree)")
176
+ for branch in skipped:
177
+ warning(f" โ€ข ๐ŸŒฟ {branch}")
184
178
  if failed:
185
179
  warning(f"โŒ Failed: {len(failed)}")
186
180
  for branch in failed: