@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.
Files changed (54) hide show
  1. package/README.md +58 -26
  2. package/bin/art.py +22 -20
  3. package/bin/branchsync.py +324 -0
  4. package/bin/che.py +208 -587
  5. package/bin/commands.py +219 -231
  6. package/bin/doctor.py +220 -0
  7. package/bin/drives/eject-all-drives.py +21 -38
  8. package/bin/drives/mount-all-drives.py +18 -62
  9. package/bin/drives/ping-nas.py +77 -105
  10. package/bin/files/compress-folders.py +7 -11
  11. package/bin/files/delete-by-ext.py +10 -14
  12. package/bin/files/delete-empty-folders.py +33 -37
  13. package/bin/files/delete-smb-files.py +12 -21
  14. package/bin/files/files-under-size.py +5 -11
  15. package/bin/files/find-largest-files.py +21 -25
  16. package/bin/files/list-permission.py +40 -0
  17. package/bin/files/make-alpha-dir.py +2 -0
  18. package/bin/git/all-actions.py +350 -253
  19. package/bin/git/checkout-my-branches.py +42 -63
  20. package/bin/git/clean-stale-branches.py +23 -48
  21. package/bin/git/prune-worktrees.py +12 -51
  22. package/bin/git/sync-all-branches.py +75 -297
  23. package/bin/git/update-local-branches.py +67 -73
  24. package/bin/gitutils.py +263 -0
  25. package/bin/install.py +393 -1182
  26. package/bin/machine.py +345 -0
  27. package/bin/media.py +127 -0
  28. package/bin/menu.py +437 -0
  29. package/bin/nas.py +102 -0
  30. package/bin/rcfile.py +380 -0
  31. package/bin/shellgen.py +503 -431
  32. package/bin/system/btop-launch.py +17 -13
  33. package/bin/system/update-brew.py +57 -68
  34. package/bin/tui.py +156 -153
  35. package/bin/utils.py +165 -237
  36. package/bin/video/delete-duplicate-videos.py +64 -62
  37. package/bin/video/detect-green-magenta-videos.py +105 -95
  38. package/bin/video/find-movie-by-year.py +3 -0
  39. package/bin/video/find-video-mkv-issues.py +48 -65
  40. package/bin/video/fix-codecs.py +25 -30
  41. package/bin/video/largest-tv-shows.py +60 -57
  42. package/bin/video/remove-metadata.py +3 -6
  43. package/bin/video/rename-video-file.py +74 -84
  44. package/bin/video/scan-videos-audio-language.py +16 -33
  45. package/bin/video/show-codecs.py +20 -24
  46. package/bin/video/validate-video-files.py +5 -8
  47. package/bin/video/video-list.py +17 -20
  48. package/package.json +4 -10
  49. package/shell/che.bash +33 -33
  50. package/shell/che.fish +7 -7
  51. package/shell/che.zsh +33 -33
  52. package/shell/completions/_che +19 -19
  53. package/shell/completions/che.bash +15 -15
  54. package/shell/completions/che.fish +34 -50
@@ -1,366 +1,144 @@
1
1
  #!/usr/bin/env python3
2
- """๐Ÿ”„ Sync everything: push every worktree, collapse the pushed ones, then tidy branches.
2
+ """๐Ÿ”„ Update every branch and worktree, keeping checkouts, locks and local work.
3
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.
9
-
10
- 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.
4
+ Fetch once, rebase branches onto their upstream (or origin/<branch>), and track
5
+ new origin branches authored by you. Dirty tracked/untracked work is saved in
6
+ an exact recovery stash and restored with its index. Conflicts are reported;
7
+ independent branches continue. Nothing is pushed, hard reset or deleted.
22
8
 
23
9
  Usage:
24
- sync-all-branches.py [--dry-run] [--no-push] [--keep-locked]
25
- [--author=EMAIL] [--limit=N]
10
+ sync-all-branches.py [--dry-run] [--author=EMAIL] [--limit=N]
26
11
 
27
12
  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)
32
- --author=EMAIL Passed through to checkout-my-branches
33
- --limit=N Passed through to checkout-my-branches
13
+ --dry-run Preview cached refs only; no fetch or changes
14
+ --author=EMAIL Author of new origin branches (default: git config user.email)
15
+ --limit=N Recent origin refs to scan for new branches (default: 100)
34
16
  --help Show help
35
17
 
36
18
  Env:
37
- DRY_RUN=true Same as --dry-run (used by the .zshrc -dr wrapper)
19
+ DRY_RUN=true Same as --dry-run (used by the shell -dr wrapper)
38
20
  """
39
21
 
40
22
  from __future__ import annotations
41
23
 
24
+ import argparse
25
+ import subprocess
42
26
  import sys
43
27
  from pathlib import Path
44
28
 
45
29
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
46
30
 
31
+ from branchsync import BranchSync
32
+ from gitutils import current_branch, fetch_remotes, for_each_ref, local_branches, skip_git_hooks
47
33
  from utils import (
48
34
  add_bool_flag,
49
- branch_is_pushed,
50
35
  build_parser,
51
36
  env_bool,
52
- find_main_branch,
37
+ heading,
53
38
  info,
54
39
  log,
55
- main_worktree,
56
- note,
40
+ parse_count,
57
41
  require_binary,
58
42
  run,
59
43
  run_cli,
60
44
  run_output,
61
- success,
62
45
  warning,
63
- worktree_branch_map,
64
- worktree_is_clean,
65
46
  )
66
47
 
67
- __version__ = "1.0.0"
68
-
69
- SCRIPT_DIR = Path(__file__).resolve().parent
70
-
48
+ __version__ = "3.0.0"
71
49
 
72
- def delegate(script: str, args: list[str], *, cwd: Path, dry_run: bool) -> None:
73
- """Run a sibling script in the main worktree, aborting the sync if it fails.
74
50
 
75
- The shell version invoked these under ``set -e``, so a non-zero exit from
76
- any of them ended the run. ``check=True`` reproduces that: a branch that
77
- could not be rebased is a reason to stop and look, not to sail on.
78
-
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"},
51
+ def track_new(sync: BranchSync, author: str, limit: int) -> list[str]:
52
+ tracked: list[str] = []
53
+ local = {branch for (branch,) in local_branches(cwd=sync.root)}
54
+ refs = for_each_ref(
55
+ "refs/remotes/origin/",
56
+ "authoremail:trim",
57
+ "refname:lstrip=3",
58
+ "symref",
59
+ sort="-committerdate",
60
+ cwd=sync.root,
86
61
  )
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.
101
- """
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}")
62
+ for email, branch, symref in refs[:limit]:
63
+ if symref or email != author or branch in local:
123
64
  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}")
65
+ if sync.dry_run:
66
+ info(f"๐ŸŒต Would track: {branch}")
152
67
  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})")
68
+ if run(
69
+ ["git", "branch", "--track", branch, f"refs/remotes/origin/{branch}"],
70
+ cwd=sync.root,
71
+ check=False,
72
+ ).returncode:
73
+ sync.block(branch, "could not create tracking branch")
171
74
  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}")
75
+ tracked.append(branch)
76
+ return tracked
206
77
 
207
78
 
208
79
  def main(argv: list[str] | None = None) -> int:
209
80
  parser = build_parser(
210
81
  prog="sync-all-branches",
211
- description="๐Ÿ”„ Push every worktree, collapse the pushed ones, then tidy branches.",
82
+ description="๐Ÿ”„ Update every branch and worktree, preserving locks and local work.",
212
83
  )
213
84
  add_bool_flag(
214
85
  parser,
215
86
  "--dry-run",
216
87
  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)",
88
+ help="Preview cached refs only; no fetch or changes",
229
89
  )
90
+ # Compatibility with chip and older wrappers. Neither pushing nor lock
91
+ # removal is ever part of syncing, regardless of these legacy flags.
92
+ add_bool_flag(parser, "--no-push", allow_value=False, help=argparse.SUPPRESS)
93
+ add_bool_flag(parser, "--keep-locked", help=argparse.SUPPRESS)
230
94
  parser.add_argument(
231
95
  "--author",
232
96
  default="",
233
97
  metavar="EMAIL",
234
- help="Passed through to checkout-my-branches",
98
+ help="Author of new origin branches (default: git config user.email)",
235
99
  )
236
100
  parser.add_argument(
237
101
  "--limit",
238
- default="",
102
+ default="100",
239
103
  metavar="N",
240
- help="Passed through to checkout-my-branches",
104
+ help="Recent origin refs to scan for new branches (default: 100)",
241
105
  )
242
106
  args = parser.parse_args(argv)
243
-
107
+ limit = parse_count(args.limit, "--limit", minimum=1)
244
108
  require_binary("git")
245
-
246
- inside = run(["git", "rev-parse", "--is-inside-work-tree"], check=False, capture=True)
247
- if inside.returncode != 0:
109
+ skip_git_hooks()
110
+ heading("๐Ÿ”„ sync-all-branches", dry_run=args.dry_run)
111
+ if run_output(["git", "rev-parse", "--is-inside-work-tree"], check=False) != "true":
248
112
  warning("โŒ Not inside a git working tree.")
249
113
  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()
269
-
270
- main_branch = find_main_branch(main_wt)
271
- if not main_branch:
272
- warning("โŒ Neither 'main' nor 'master' exists locally โ€” refusing to sync.")
114
+ root = Path(run_output(["git", "rev-parse", "--show-toplevel"]))
115
+ author = args.author or run_output(["git", "config", "user.email"], cwd=root, check=False)
116
+ if not author:
117
+ warning("โŒ No author email; pass --author=EMAIL or set git config user.email.")
273
118
  return 1
274
-
275
- # -----------------------------------------------------------------------
276
- # Step 1 โ€” fetch + prune
277
- # -----------------------------------------------------------------------
278
-
279
- info("๐Ÿ“ก Fetching + pruning remotes...")
119
+ original = current_branch(root) or run_output(["git", "rev-parse", "--short", "HEAD"], cwd=root)
120
+ info(f"๐Ÿ“ Keeping caller checkout: {original}")
121
+ # DELIBERATE DIVERGENCE: the old sync parked on main and delegated cleanup.
122
+ # Preserve active workspaces instead, and never discard a gone-upstream
123
+ # branch merely because its remote was deleted. Dry-run must not fetch.
280
124
  if args.dry_run:
281
- info("๐ŸŒต Would run: git fetch --all --prune")
125
+ info("๐ŸŒต Cached remote refs only; no fetch. Results reflect the last fetch.")
282
126
  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
- # -----------------------------------------------------------------------
288
-
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
- # -----------------------------------------------------------------------
299
-
300
- if args.dry_run:
301
- info("๐ŸŒต Would run: git worktree prune")
302
- 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.
127
+ fetch_remotes()
128
+ sync = BranchSync(root, args.dry_run)
129
+ sync.update()
337
130
  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
131
+ tracked = track_new(sync, author, limit)
132
+ except (subprocess.CalledProcessError, OSError) as exc:
133
+ sync.block("new origin branches", str(exc))
134
+ tracked = []
135
+ sync.summary(tracked)
136
+ if sync.blocked:
137
+ warning(
138
+ "Sync partially completed; blocked branches need attention. Remaining independent branches were attempted."
358
139
  )
359
- raise
360
-
361
- report(pushed=pushed, collapsed=collapsed, kept=kept, started_in=started_in, main_wt=main_wt)
362
-
363
- log("๐ŸŽ‰ Sync complete.")
140
+ return 1
141
+ log("๐ŸŽ‰ Preview complete." if args.dry_run else "๐ŸŽ‰ Sync complete.")
364
142
  return 0
365
143
 
366
144
 
@@ -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: