@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/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
|
-
[](https://github.com/codebend3r/che/actions/workflows/sanity-check.yml)
|
|
9
9
|
[](https://www.npmjs.com/package/@codebend3r/che)
|
|
10
10
|

|
|
11
11
|

|
|
@@ -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/
|
|
83
|
-
|
|
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/
|
|
144
|
+
export CHE_HOME=/Users/you/Developer/git/che
|
|
143
145
|
export CHE_PYTHON=/opt/homebrew/bin/python3
|
|
144
|
-
[ -r /Users/you/Developer/git/
|
|
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
|
|
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` |
|
|
223
|
-
| `update-from-origin` |
|
|
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
|
|
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
|
-
|
|
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
|
|
363
|
+
## Syncing branches
|
|
355
364
|
|
|
356
365
|
```sh
|
|
357
|
-
sync-all-branches #
|
|
358
|
-
sync-all-branches-dr # preview
|
|
359
|
-
update-from-origin
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 +
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
)
|