@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
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  **A collection of helper scripts shared amongst computers, called by name from
6
6
  your shell, and browsable through one menu.**
7
7
 
8
- [![CI](https://github.com/codebend3r/che/actions/workflows/ci.yml/badge.svg)](https://github.com/codebend3r/che/actions/workflows/ci.yml)
8
+ [![CI](https://github.com/codebend3r/che/actions/workflows/sanity-check.yml/badge.svg)](https://github.com/codebend3r/che/actions/workflows/sanity-check.yml)
9
9
  [![npm](https://img.shields.io/npm/v/%40codebend3r%2Fche)](https://www.npmjs.com/package/@codebend3r/che)
10
10
  ![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)
11
11
  ![Dependencies: stdlib only](https://img.shields.io/badge/dependencies-stdlib%20only-brightgreen)
@@ -79,8 +79,8 @@ git clone https://github.com/codebend3r/che.git ~/Developer/git/che
79
79
  ~/Developer/git/che/install.sh
80
80
  ```
81
81
 
82
- Or in one line, which clones for you (into `~/Developer/git/shell-functions`,
83
- or wherever `CHE_HOME` points):
82
+ Or in one line, which clones for you (into `~/Developer/git/che`, or wherever
83
+ `CHE_HOME` points):
84
84
 
85
85
  ```sh
86
86
  curl -fsSL https://raw.githubusercontent.com/codebend3r/che/main/install.sh | bash
@@ -103,7 +103,9 @@ which opens a first-run wizard: it lists the shells it found and the rc files
103
103
  it would touch, asks whether to add a `che` on `PATH` and whether to keep the
104
104
  completion chimes, offers to remove hand-written wrappers left over from before
105
105
  the installer existed, and reports any missing external tool (offering to
106
- `brew install` them when Homebrew is present).
106
+ `brew install` them when Homebrew is present). A question already answered on
107
+ the command line (`--no-sounds`, `--no-path-shim`, `--replace-legacy`,
108
+ `--install-deps`) is not asked again.
107
109
 
108
110
  <div align="center">
109
111
  <img src="first-run.png" alt="The first run screen: che is not installed in this shell yet, offering to install, open the menu, or quit" width="700">
@@ -118,7 +120,7 @@ Skip the wizard:
118
120
  ./install.sh --yes --shells=zsh,fish # or the ones you name
119
121
  ./install.sh --dry-run # report what would change, write nothing
120
122
  ./install.sh --yes --replace-legacy # also remove pre-che hand-written wrappers
121
- ./install.sh --print # print the rc block, install nothing
123
+ ./install.sh --print # print the exact rc block, install nothing
122
124
  ```
123
125
 
124
126
  Every rc file is copied to `~/.config/che/backups/` before it is edited.
@@ -139,9 +141,9 @@ dotfiles:
139
141
  ```zsh
140
142
  # >>> che shell functions >>>
141
143
  # Managed by `che install` (che 1.0.0). Edits here are overwritten.
142
- export CHE_HOME=/Users/you/Developer/git/shell-functions
144
+ export CHE_HOME=/Users/you/Developer/git/che
143
145
  export CHE_PYTHON=/opt/homebrew/bin/python3
144
- [ -r /Users/you/Developer/git/shell-functions/shell/che.zsh ] && . /Users/you/Developer/git/shell-functions/shell/che.zsh
146
+ [ -r /Users/you/Developer/git/che/shell/che.zsh ] && . /Users/you/Developer/git/che/shell/che.zsh
145
147
  case ":$PATH:" in *":/Users/you/.local/bin:"*) ;; *) PATH="/Users/you/.local/bin:$PATH" ;; esac
146
148
  # <<< che shell functions <<<
147
149
  ```
@@ -164,7 +166,7 @@ python3 exists. `brew install python@3.13` if none does.
164
166
 
165
167
  ```sh
166
168
  che uninstall # remove the block, the shim and the completions
167
- che uninstall --purge # also delete ~/.config/che
169
+ che uninstall --purge # also delete ~/.config/che and the menu's input history
168
170
  ```
169
171
 
170
172
  The repo itself is left alone; delete the directory to finish.
@@ -214,13 +216,13 @@ have used, and `che help <command>` prints that script's own `--help`.
214
216
  | Command | Does | Preview |
215
217
  | --- | --- | --- |
216
218
  | **Git** | *Branch hygiene, worktrees and GitHub Actions* | |
217
- | `all-actions` | GitHub Actions status for every open PR you authored | |
219
+ | `all-actions` | GitHub Actions status for every open PR and every default branch | |
218
220
  | `all-actions-watch` | Same as all-actions, refreshed on an interval | |
219
221
  | `checkout-my-branches` | Check out recent remote branches you authored that aren't local yet | |
220
222
  | `clean-stale-branches` | Delete local branches whose upstream is gone | `clean-stale-branches-dr` |
221
223
  | `prune-worktrees` | Remove every linked worktree, then prune stale admin records | `prune-worktrees-dr` |
222
- | `sync-all-branches` | Push every worktree, collapse the pushed ones, then tidy branches | `sync-all-branches-dr` |
223
- | `update-from-origin` | Same as sync-all-branches but never pushes: fetch, collapse, rebase | |
224
+ | `sync-all-branches` | Update every branch and worktree, preserving locks and local work | `sync-all-branches-dr` |
225
+ | `update-from-origin` | Alias of sync-all-branches | `--dry-run` |
224
226
  | `update-local-branches` | Rebase every local branch that has an upstream onto origin | `--dry-run` |
225
227
  | **Video** | *Codecs, renaming, dedupe and metadata* | |
226
228
  | `show-codecs` | Report media files outside the Direct Play codec/container set | |
@@ -316,9 +318,16 @@ where you copy one machine's rc file to another.
316
318
  ```
317
319
  bin/
318
320
  art.py the wordmark and the face beside it, for every screen
319
- che.py the dispatcher and the interactive menu
321
+ che.py the dispatcher, and the loop that runs what the menu picks
320
322
  commands.py the command manifest: the source of truth for everything
321
- install.py installer, first-run wizard, doctor, self-update
323
+ doctor.py `che doctor`: every check on the install
324
+ gitutils.py shared git helpers: worktrees, main branch, hook skipping
325
+ install.py installer, first-run wizard, self-update, uninstall
326
+ machine.py shells, rc file paths, python, config: facts about the machine
327
+ media.py shared ffprobe helpers for the video scripts
328
+ menu.py the menu's list, keys and drawing
329
+ nas.py the NAS drive table and SMB mounting, for the drive scripts
330
+ rcfile.py edits to rc files: the che block and old hand-written wrappers
322
331
  shellgen.py renders the wrappers, completions and README tables
323
332
  tui.py terminal primitives: raw input, frame buffer, prompts
324
333
  utils.py shared library: logging, arg parsing, subprocess, files
@@ -351,22 +360,45 @@ Every script follows the same shape, described in full at the top of
351
360
  point, so pointing a delete tool at `~/media -> /Volumes/...` used to be a
352
361
  silent no-op. Pass the real path to act on it.
353
362
 
354
- ## Syncing branches and worktrees
363
+ ## Syncing branches
355
364
 
356
365
  ```sh
357
- sync-all-branches # push every worktree, collapse the pushed ones, then sync
358
- sync-all-branches-dr # preview only
359
- update-from-origin # same, but --no-push: only collapses already-pushed worktrees
366
+ sync-all-branches # update branches and worktrees, keeping locks and local work
367
+ sync-all-branches-dr # preview cached remote refs; no fetch or changes
368
+ update-from-origin # alias of sync-all-branches
360
369
  ```
361
370
 
362
- A worktree is a temporary workspace. Once its commits are on origin the
363
- directory holds nothing the remote doesn't, so `sync-all-branches` pushes it,
364
- removes the directory, and leaves the branch behind as an ordinary local
365
- branch. That also clears the `already used by worktree` pin, which is what
366
- otherwise makes `main` un-checkoutable and stale branches un-deletable.
367
-
368
- A worktree is only collapsed when it is **clean** *and* **fully pushed**.
369
- Anything else is kept and reported with the reason.
371
+ Fetches once and rebases every local branch onto its upstream, falling back to
372
+ `origin/<branch>` when no upstream is configured. Branches in existing
373
+ worktrees, including locked ones, update **inside their own checkout**. Dirty
374
+ tracked and untracked work is temporarily stashed and restored with its staged
375
+ state; unrelated existing stashes survive. Nested registered worktrees are
376
+ excluded from their parent's stash. Dirty submodules and existing Git operations
377
+ are reported and left alone.
378
+
379
+ It **never pushes, hard resets or deletes local branches**, and preserves every
380
+ existing worktree directory, lock and checkout, including the caller's branch.
381
+ Unoccupied branches use temporary worktrees that are removed after updating.
382
+ New origin branches authored by you become local tracking branches without
383
+ switching HEAD. Missing upstreams are reported, not treated as proof of a merge;
384
+ use `clean-stale-branches` separately when you want branch cleanup.
385
+
386
+ Rebase conflicts are aborted and reported while other branches continue. If
387
+ restoring WIP conflicts, the exact recovery stash is retained and its ID and
388
+ worktree path are printed. A blocked branch makes the command exit nonzero even
389
+ when other branches updated. Local-only branches are reported without inventing
390
+ a remote target. Ahead counts may remain because local commits are kept; clearing
391
+ them would require pushing or discarding work. Avoid concurrent edits or Git
392
+ operations while the sync temporarily saves WIP.
393
+
394
+ The `-dr` preview does not fetch and only inspects cached remote refs. It changes
395
+ no branches, working files, locks or stashes. Legacy `--no-push` and
396
+ `--keep-locked` flags are still accepted for compatibility.
397
+
398
+ Like the other branch scripts (`clean-stale-branches`, `update-local-branches`,
399
+ `checkout-my-branches`, `prune-worktrees`), sync retains che's existing policy of
400
+ pointing `core.hooksPath` at `/dev/null` for its Git commands so bookkeeping does
401
+ not run repository hooks.
370
402
 
371
403
  ## External tools
372
404
 
@@ -408,7 +440,7 @@ This repo uses [Bun](https://bun.sh) as its task runner and
408
440
  ```sh
409
441
  bun run test # pytest
410
442
  bun run generate # rewrite shell/ and this README's tables from the manifest
411
- bun run ci # ruff + syntax + exec bits + shebangs + generated files + pytest
443
+ bun run ci # ruff + exec bits + shebangs + pytest (stale generated files fail it)
412
444
  ```
413
445
 
414
446
  Neither `pytest` nor `ruff` needs to be installed globally: the scripts shell
package/bin/art.py CHANGED
@@ -56,28 +56,20 @@ INDENT = " "
56
56
  GAP = 1
57
57
 
58
58
 
59
- def logo_lines() -> list[str]:
60
- return LOGO.strip("\n").splitlines()
59
+ LOGO_LINES: tuple[str, ...] = tuple(LOGO.strip("\n").splitlines())
60
+ FACE_LINES: tuple[str, ...] = tuple(FACE.strip("\n").splitlines())
61
61
 
62
+ HEIGHT = len(FACE_LINES)
63
+ WIDTH = len(INDENT) + max(len(line) for line in FACE_LINES)
62
64
 
63
- def face_lines() -> list[str]:
64
- return FACE.strip("\n").splitlines()
65
65
 
66
+ def _paint(lines: Sequence[str], colors: tui.Palette, indent: str = "") -> list[str]:
67
+ return [f"{colors.bright_magenta}{indent}{line}{colors.reset}" for line in lines]
66
68
 
67
- HEIGHT = len(face_lines())
68
- WIDTH = len(INDENT) + max(len(line) for line in face_lines())
69
69
 
70
-
71
- def wordmark(colors: tui.Palette | None = None) -> list[str]:
70
+ def wordmark(colors: tui.Palette) -> list[str]:
72
71
  """The block letters, coloured the way every screen prints them."""
73
- colors = colors or tui.palette()
74
- return [f"{colors.bright_magenta}{line}{colors.reset}" for line in logo_lines()]
75
-
76
-
77
- def face(colors: tui.Palette | None = None) -> list[str]:
78
- """The face, indented to line up with the wordmark and in the same colour."""
79
- colors = colors or tui.palette()
80
- return [f"{colors.bright_magenta}{INDENT}{line}{colors.reset}" for line in face_lines()]
72
+ return _paint(LOGO_LINES, colors)
81
73
 
82
74
 
83
75
  def compose(left: Sequence[str], right: Sequence[str], *, gap: int = GAP) -> list[str]:
@@ -105,17 +97,27 @@ def fits(right: Sequence[str], columns: int) -> bool:
105
97
  return WIDTH + GAP + right_width <= columns
106
98
 
107
99
 
108
- def banner(
109
- right: Sequence[str], *, columns: int | None = None, colors: tui.Palette | None = None
110
- ) -> list[str]:
100
+ def banner(right: Sequence[str], *, colors: tui.Palette, columns: int | None = None) -> list[str]:
111
101
  """The face with ``right`` beside it, or ``right`` alone when it would not fit.
112
102
 
113
103
  ``right`` is normally the wordmark plus whatever a screen wants under it (a
114
104
  subtitle, the command count). It comes back untouched, indent and all, on a
115
105
  terminal too narrow for both, so every caller keeps working at 40 columns.
106
+ The face is indented to line up with the wordmark and painted in its colour.
116
107
  """
117
108
  if columns is None:
118
109
  columns = tui.size()[0]
119
110
  if not fits(right, columns):
120
111
  return list(right)
121
- return compose(face(colors), right)
112
+ return compose(_paint(FACE_LINES, colors, INDENT), right)
113
+
114
+
115
+ def print_logo(subtitle: str = "") -> None:
116
+ """The face beside the wordmark, with ``subtitle`` under the letters."""
117
+ colors = tui.palette()
118
+ right = wordmark(colors)
119
+ if subtitle:
120
+ right.append(f" {colors.grey}{subtitle}{colors.reset}")
121
+ for line in banner(right, colors=colors):
122
+ print(line)
123
+ print()
@@ -0,0 +1,324 @@
1
+ """Non-destructive branch/worktree synchronization for sync-all-branches.
2
+
3
+ Unlike the old delegated cleanup, sync preserves every checkout and local branch.
4
+ Only disposable worktrees created here may be removed. Git locks need no special
5
+ handling: they prevent removal/pruning, not a rebase inside the locked checkout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ import tempfile
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from gitutils import Worktree, local_branches, worktrees
16
+ from utils import info, run, run_output, success, warning
17
+
18
+ _OPERATION_FILES = (
19
+ "rebase-merge",
20
+ "rebase-apply",
21
+ "MERGE_HEAD",
22
+ "CHERRY_PICK_HEAD",
23
+ "REVERT_HEAD",
24
+ "BISECT_START",
25
+ )
26
+
27
+
28
+ def git_path(path: Path, name: str) -> Path:
29
+ result = Path(run_output(["git", "rev-parse", "--git-path", name], cwd=path))
30
+ return result if result.is_absolute() else path / result
31
+
32
+
33
+ def operation_branch(path: Path) -> str:
34
+ """A rebase/bisect detaches HEAD but still reserves its original branch."""
35
+ for name in ("rebase-merge/head-name", "rebase-apply/head-name", "BISECT_START"):
36
+ file = git_path(path, name)
37
+ if file.is_file():
38
+ return file.read_text().splitlines()[0].removeprefix("refs/heads/")
39
+ return ""
40
+
41
+
42
+ def in_progress(path: Path) -> bool:
43
+ return any(git_path(path, name).exists() for name in _OPERATION_FILES)
44
+
45
+
46
+ @dataclass
47
+ class BranchSync:
48
+ root: Path
49
+ dry_run: bool
50
+ updated: list[str] = field(default_factory=list)
51
+ current: list[str] = field(default_factory=list)
52
+ local_only: list[str] = field(default_factory=list)
53
+ restored: list[str] = field(default_factory=list)
54
+ blocked: list[str] = field(default_factory=list)
55
+ trees: list[Worktree] = field(init=False)
56
+
57
+ def __post_init__(self) -> None:
58
+ self.trees = worktrees(self.root)
59
+
60
+ def block(self, branch: str, reason: str) -> None:
61
+ message = f"{branch}: {reason}"
62
+ self.blocked.append(message)
63
+ warning(f" ❌ {message}")
64
+
65
+ def occupied(self) -> dict[str, Path]:
66
+ result: dict[str, Path] = {}
67
+ for tree in self.trees:
68
+ path = Path(tree.path)
69
+ branch = tree.branch
70
+ if not branch and path.is_dir():
71
+ branch = operation_branch(path)
72
+ if branch:
73
+ result[branch] = path
74
+ else:
75
+ info(f" â„šī¸ Detached worktree unchanged (no upstream): {path}") # noqa: RUF001
76
+ return result
77
+
78
+ def pathspecs(self, path: Path) -> list[str]:
79
+ """Never stash another registered checkout nested inside this one.
80
+
81
+ Restrict status and stash by the same literal pathspecs, so untracked
82
+ siblings still count as WIP while a nested worktree stays untouched.
83
+ """
84
+ specs = ["."]
85
+ for tree in self.trees:
86
+ nested = Path(tree.path)
87
+ if nested != path and nested.is_relative_to(path):
88
+ specs.append(f":(exclude,literal){nested.relative_to(path).as_posix()}")
89
+ return specs
90
+
91
+ def dirty(self, path: Path) -> bool:
92
+ return bool(
93
+ run_output(
94
+ [
95
+ "git",
96
+ "status",
97
+ "--porcelain",
98
+ "-z",
99
+ "--untracked-files=all",
100
+ "--",
101
+ *self.pathspecs(path),
102
+ ],
103
+ cwd=path,
104
+ )
105
+ )
106
+
107
+ def has_ref(self, ref: str) -> bool:
108
+ return (
109
+ run(
110
+ ["git", "show-ref", "--verify", "--quiet", ref],
111
+ cwd=self.root,
112
+ check=False,
113
+ capture=True,
114
+ ).returncode
115
+ == 0
116
+ )
117
+
118
+ def includes(self, branch: str, upstream: str) -> bool:
119
+ result = run(
120
+ ["git", "merge-base", "--is-ancestor", upstream, f"refs/heads/{branch}"],
121
+ cwd=self.root,
122
+ check=False,
123
+ capture=True,
124
+ )
125
+ if result.returncode not in (0, 1):
126
+ raise subprocess.CalledProcessError(
127
+ result.returncode, result.args, stderr=result.stderr
128
+ )
129
+ return result.returncode == 0
130
+
131
+ def save_wip(self, branch: str, path: Path) -> str:
132
+ if not self.dirty(path):
133
+ return ""
134
+ # A superproject stash cannot preserve dirty submodule working copies.
135
+ submodules = run(
136
+ ["git", "submodule", "foreach", "--quiet", "--recursive", "git status --porcelain"],
137
+ cwd=path,
138
+ check=False,
139
+ capture=True,
140
+ )
141
+ if submodules.returncode or submodules.stdout:
142
+ raise OSError(f"dirty/unreadable submodule in {path}; left untouched")
143
+ before = run_output(["git", "rev-parse", "--verify", "refs/stash"], cwd=path, check=False)
144
+ run(
145
+ [
146
+ "git",
147
+ "stash",
148
+ "push",
149
+ "--include-untracked",
150
+ "--message",
151
+ f"che sync-all-branches: {branch}",
152
+ "--",
153
+ *self.pathspecs(path),
154
+ ],
155
+ cwd=path,
156
+ )
157
+ oid = run_output(["git", "rev-parse", "--verify", "refs/stash"], cwd=path)
158
+ if not oid or oid == before:
159
+ raise OSError(
160
+ f"could not verify a new recovery stash in {path}; inspect git stash list"
161
+ )
162
+ info(f" đŸ§ē Recovery stash for {branch} in {path}: {oid}")
163
+ if self.dirty(path):
164
+ raise OSError(f"worktree remains dirty; left untouched, recovery stash {oid} in {path}")
165
+ return oid
166
+
167
+ def restore_wip(self, branch: str, path: Path, oid: str) -> bool:
168
+ if not oid:
169
+ return True
170
+ # Worktrees share refs/stash. Never pop the latest entry or drop a
171
+ # recovery copy until both working-copy and index restoration succeed.
172
+ if run(["git", "stash", "apply", "--index", oid], cwd=path, check=False).returncode:
173
+ self.block(
174
+ branch, f"WIP restoration conflicted in {path}; recovery stash retained: {oid}"
175
+ )
176
+ return False
177
+ entries = run_output(["git", "stash", "list", "--format=%gd%x01%H"], cwd=path)
178
+ matching = next(
179
+ (
180
+ ref
181
+ for line in entries.splitlines()
182
+ for ref, sha in [line.split("\x01")]
183
+ if sha == oid
184
+ ),
185
+ "",
186
+ )
187
+ if (
188
+ not matching
189
+ or run(["git", "stash", "drop", "--quiet", matching], cwd=path, check=False).returncode
190
+ ):
191
+ self.block(branch, f"WIP restored in {path}, but recovery stash not dropped: {oid}")
192
+ return False
193
+ self.restored.append(branch)
194
+ return True
195
+
196
+ def sync_branch(self, branch: str, upstream: str, occupied: dict[str, Path]) -> None:
197
+ path = occupied.get(branch)
198
+ if path is not None:
199
+ if not path.is_dir():
200
+ self.block(branch, f"worktree unavailable: {path}")
201
+ return
202
+ if in_progress(path):
203
+ self.block(branch, f"operation in progress in {path}; left untouched")
204
+ return
205
+ if self.includes(branch, upstream):
206
+ info(f" = Up to date: {branch} (local commits kept){f' in {path}' if path else ''}")
207
+ self.current.append(branch)
208
+ return
209
+ if self.dry_run:
210
+ info(
211
+ f" đŸŒĩ Would update {branch} from {upstream}{f' in {path}' if path else ''}; stash/restore WIP if needed"
212
+ )
213
+ return
214
+
215
+ temporary: Path | None = None
216
+ oid = ""
217
+ try:
218
+ if path is None:
219
+ temporary = Path(tempfile.mkdtemp(prefix="che-sync-"))
220
+ path = temporary / "checkout"
221
+ run(["git", "worktree", "add", "--quiet", str(path), branch], cwd=self.root)
222
+ oid = self.save_wip(branch, path)
223
+ info(f"âŦ‡ī¸ Rebasing {branch} in {path} (lock preserved)")
224
+ # DELIBERATE DIVERGENCE: one fetched snapshot instead of pull's
225
+ # per-branch fetch. Disable autostash/updateRefs so Git cannot
226
+ # compete with our exact WIP recovery or move unrelated branches.
227
+ rebased = (
228
+ run(
229
+ [
230
+ "git",
231
+ "-c",
232
+ "rebase.autoStash=false",
233
+ "-c",
234
+ "rebase.updateRefs=false",
235
+ "rebase",
236
+ "--no-autostash",
237
+ upstream,
238
+ ],
239
+ cwd=path,
240
+ check=False,
241
+ ).returncode
242
+ == 0
243
+ )
244
+ if not rebased:
245
+ warning(f" ❌ Rebase failed for {branch} in {path}")
246
+ if in_progress(path):
247
+ # Abort only the rebase we started. Failure to abort is
248
+ # fatal: applying WIP or continuing is no longer safe.
249
+ abort = run(["git", "rebase", "--abort"], cwd=path, check=False)
250
+ if abort.returncode:
251
+ warning(f"STOP: cannot abort in {path}; recovery stash: {oid or 'none'}")
252
+ raise SystemExit(1)
253
+ self.block(branch, f"rebase failed in {path}; original commits retained")
254
+ restored = self.restore_wip(branch, path, oid)
255
+ if rebased and restored:
256
+ if self.includes(branch, upstream):
257
+ self.updated.append(branch)
258
+ success(f" ✅ Updated: {branch}")
259
+ else:
260
+ self.block(branch, "verification failed: fetched upstream not included")
261
+ except (subprocess.CalledProcessError, OSError) as exc:
262
+ self.block(
263
+ branch, f"{exc}; inspect {path}; recovery stash: {oid or 'see git stash list'}"
264
+ )
265
+ except KeyboardInterrupt:
266
+ warning(
267
+ f"Interrupted in {path}; inspect status before resuming. Recovery stash: {oid or 'see git stash list'}"
268
+ )
269
+ raise
270
+ finally:
271
+ # Never use TemporaryDirectory here: its automatic recursive
272
+ # deletion would erase files if a hook or recovery left a dirty tree.
273
+ if temporary is not None:
274
+ if not (temporary / "checkout").exists():
275
+ temporary.rmdir()
276
+ elif (
277
+ not in_progress(temporary / "checkout")
278
+ and run(
279
+ ["git", "worktree", "remove", str(temporary / "checkout")],
280
+ cwd=self.root,
281
+ check=False,
282
+ ).returncode
283
+ == 0
284
+ ):
285
+ temporary.rmdir()
286
+ else:
287
+ self.block(
288
+ branch,
289
+ f"temporary worktree retained for inspection: {temporary / 'checkout'}",
290
+ )
291
+
292
+ def update(self) -> None:
293
+ occupied = self.occupied()
294
+ for branch, upstream in local_branches("upstream", cwd=self.root):
295
+ if not upstream and self.has_ref(f"refs/remotes/origin/{branch}"):
296
+ upstream = f"refs/remotes/origin/{branch}"
297
+ if not upstream:
298
+ info(f" â„šī¸ Local only: {branch} (no remote counterpart)") # noqa: RUF001
299
+ self.local_only.append(branch)
300
+ continue
301
+ if not self.has_ref(upstream):
302
+ self.block(branch, f"upstream gone: {upstream}; branch and worktree retained")
303
+ continue
304
+ try:
305
+ self.sync_branch(branch, upstream, occupied)
306
+ except (subprocess.CalledProcessError, OSError) as exc:
307
+ self.block(branch, f"preflight failed: {exc}")
308
+
309
+ def summary(self, tracked: list[str]) -> None:
310
+ success("═══ summary ═══")
311
+ for label, names in (
312
+ ("Updated", self.updated),
313
+ ("Up to date", self.current),
314
+ ("Tracked new", tracked),
315
+ ("Local only", self.local_only),
316
+ ("WIP restored", self.restored),
317
+ ):
318
+ info(f"{label}: {len(names)} ({', '.join(names)})")
319
+ info(f"Blocked: {len(self.blocked)}")
320
+ for reason in self.blocked:
321
+ warning(f" â€ĸ {reason}")
322
+ info(
323
+ "Caller checkout, existing worktree paths, locks and local commits preserved. Nothing pushed."
324
+ )