@codebend3r/che 1.2.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 +35 -17
- package/bin/branchsync.py +324 -0
- package/bin/commands.py +3 -4
- package/bin/git/sync-all-branches.py +79 -72
- package/bin/gitutils.py +4 -4
- package/package.json +1 -1
- package/shell/che.bash +3 -3
- package/shell/che.fish +3 -3
- package/shell/che.zsh +3 -3
- package/shell/completions/_che +3 -3
- package/shell/completions/che.bash +1 -1
- package/shell/completions/che.fish +3 -3
package/README.md
CHANGED
|
@@ -221,7 +221,7 @@ have used, and `che help <command>` prints that script's own `--help`.
|
|
|
221
221
|
| `checkout-my-branches` | Check out recent remote branches you authored that aren't local yet | |
|
|
222
222
|
| `clean-stale-branches` | Delete local branches whose upstream is gone | `clean-stale-branches-dr` |
|
|
223
223
|
| `prune-worktrees` | Remove every linked worktree, then prune stale admin records | `prune-worktrees-dr` |
|
|
224
|
-
| `sync-all-branches` |
|
|
224
|
+
| `sync-all-branches` | Update every branch and worktree, preserving locks and local work | `sync-all-branches-dr` |
|
|
225
225
|
| `update-from-origin` | Alias of sync-all-branches | `--dry-run` |
|
|
226
226
|
| `update-local-branches` | Rebase every local branch that has an upstream onto origin | `--dry-run` |
|
|
227
227
|
| **Video** | *Codecs, renaming, dedupe and metadata* | |
|
|
@@ -363,24 +363,42 @@ Every script follows the same shape, described in full at the top of
|
|
|
363
363
|
## Syncing branches
|
|
364
364
|
|
|
365
365
|
```sh
|
|
366
|
-
sync-all-branches #
|
|
367
|
-
sync-all-branches-dr # preview
|
|
368
|
-
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
|
|
369
369
|
```
|
|
370
370
|
|
|
371
|
-
|
|
372
|
-
`
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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.
|
|
384
402
|
|
|
385
403
|
## External tools
|
|
386
404
|
|
|
@@ -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
|
+
)
|
package/bin/commands.py
CHANGED
|
@@ -268,13 +268,13 @@ COMMANDS: tuple[Command, ...] = (
|
|
|
268
268
|
flags=("--dry-run", "--author=", "--limit="),
|
|
269
269
|
category="git",
|
|
270
270
|
icon="🔄",
|
|
271
|
-
summary="
|
|
271
|
+
summary="Update every branch and worktree, preserving locks and local work",
|
|
272
272
|
script="git/sync-all-branches.py",
|
|
273
273
|
sound=7,
|
|
274
274
|
dry_run="env",
|
|
275
275
|
destructive=True,
|
|
276
276
|
needs=("git",),
|
|
277
|
-
tags=("branch", "sync", "pull", "
|
|
277
|
+
tags=("branch", "sync", "pull", "worktree"),
|
|
278
278
|
),
|
|
279
279
|
Command(
|
|
280
280
|
name="update-from-origin",
|
|
@@ -283,8 +283,7 @@ COMMANDS: tuple[Command, ...] = (
|
|
|
283
283
|
icon="⬇️",
|
|
284
284
|
summary="Alias of sync-all-branches",
|
|
285
285
|
script="git/sync-all-branches.py",
|
|
286
|
-
# A real run
|
|
287
|
-
# a `--dry-run` typed after it still wins.
|
|
286
|
+
# A real run by default; a `--dry-run` typed after it still wins.
|
|
288
287
|
args=("--dry-run=false",),
|
|
289
288
|
sound=7,
|
|
290
289
|
destructive=True,
|
|
@@ -1,42 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""🔄
|
|
2
|
+
"""🔄 Update every branch and worktree, keeping checkouts, locks and local work.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
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.
|
|
17
8
|
|
|
18
9
|
Usage:
|
|
19
10
|
sync-all-branches.py [--dry-run] [--author=EMAIL] [--limit=N]
|
|
20
11
|
|
|
21
12
|
Options:
|
|
22
|
-
--dry-run
|
|
23
|
-
--author=EMAIL
|
|
24
|
-
--limit=N
|
|
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)
|
|
25
16
|
--help Show help
|
|
26
17
|
|
|
27
18
|
Env:
|
|
28
|
-
DRY_RUN=true Same as --dry-run (used by the
|
|
19
|
+
DRY_RUN=true Same as --dry-run (used by the shell -dr wrapper)
|
|
29
20
|
"""
|
|
30
21
|
|
|
31
22
|
from __future__ import annotations
|
|
32
23
|
|
|
33
24
|
import argparse
|
|
25
|
+
import subprocess
|
|
34
26
|
import sys
|
|
35
27
|
from pathlib import Path
|
|
36
28
|
|
|
37
29
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
38
30
|
|
|
39
|
-
from
|
|
31
|
+
from branchsync import BranchSync
|
|
32
|
+
from gitutils import current_branch, fetch_remotes, for_each_ref, local_branches, skip_git_hooks
|
|
40
33
|
from utils import (
|
|
41
34
|
add_bool_flag,
|
|
42
35
|
build_parser,
|
|
@@ -44,94 +37,108 @@ from utils import (
|
|
|
44
37
|
heading,
|
|
45
38
|
info,
|
|
46
39
|
log,
|
|
40
|
+
parse_count,
|
|
47
41
|
require_binary,
|
|
48
42
|
run,
|
|
49
43
|
run_cli,
|
|
44
|
+
run_output,
|
|
45
|
+
warning,
|
|
50
46
|
)
|
|
51
47
|
|
|
52
|
-
__version__ = "
|
|
53
|
-
|
|
54
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def delegate(script: str, args: list[str], *, cwd: Path) -> None:
|
|
58
|
-
"""Run a sibling script in the main worktree, aborting the sync if it fails.
|
|
48
|
+
__version__ = "3.0.0"
|
|
59
49
|
|
|
60
|
-
The shell version invoked these under ``set -e``, so a non-zero exit from
|
|
61
|
-
any of them ended the run. ``check=True`` reproduces that: a branch that
|
|
62
|
-
could not be rebased is a reason to stop and look, not to sail on.
|
|
63
50
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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,
|
|
61
|
+
)
|
|
62
|
+
for email, branch, symref in refs[:limit]:
|
|
63
|
+
if symref or email != author or branch in local:
|
|
64
|
+
continue
|
|
65
|
+
if sync.dry_run:
|
|
66
|
+
info(f"🌵 Would track: {branch}")
|
|
67
|
+
continue
|
|
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")
|
|
74
|
+
else:
|
|
75
|
+
tracked.append(branch)
|
|
76
|
+
return tracked
|
|
70
77
|
|
|
71
78
|
|
|
72
79
|
def main(argv: list[str] | None = None) -> int:
|
|
73
80
|
parser = build_parser(
|
|
74
81
|
prog="sync-all-branches",
|
|
75
|
-
description="🔄
|
|
82
|
+
description="🔄 Update every branch and worktree, preserving locks and local work.",
|
|
76
83
|
)
|
|
77
84
|
add_bool_flag(
|
|
78
85
|
parser,
|
|
79
86
|
"--dry-run",
|
|
80
87
|
default=env_bool("DRY_RUN", False),
|
|
81
|
-
help="
|
|
88
|
+
help="Preview cached refs only; no fetch or changes",
|
|
82
89
|
)
|
|
83
|
-
#
|
|
84
|
-
#
|
|
90
|
+
# Compatibility with chip and older wrappers. Neither pushing nor lock
|
|
91
|
+
# removal is ever part of syncing, regardless of these legacy flags.
|
|
85
92
|
add_bool_flag(parser, "--no-push", allow_value=False, help=argparse.SUPPRESS)
|
|
86
93
|
add_bool_flag(parser, "--keep-locked", help=argparse.SUPPRESS)
|
|
87
94
|
parser.add_argument(
|
|
88
95
|
"--author",
|
|
89
96
|
default="",
|
|
90
97
|
metavar="EMAIL",
|
|
91
|
-
help="
|
|
98
|
+
help="Author of new origin branches (default: git config user.email)",
|
|
92
99
|
)
|
|
93
100
|
parser.add_argument(
|
|
94
101
|
"--limit",
|
|
95
|
-
default="",
|
|
102
|
+
default="100",
|
|
96
103
|
metavar="N",
|
|
97
|
-
help="
|
|
104
|
+
help="Recent origin refs to scan for new branches (default: 100)",
|
|
98
105
|
)
|
|
99
106
|
args = parser.parse_args(argv)
|
|
100
|
-
|
|
107
|
+
limit = parse_count(args.limit, "--limit", minimum=1)
|
|
101
108
|
require_binary("git")
|
|
102
109
|
skip_git_hooks()
|
|
103
|
-
|
|
104
110
|
heading("🔄 sync-all-branches", dry_run=args.dry_run)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
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)
|
|
121
|
-
|
|
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}")
|
|
127
|
-
|
|
111
|
+
if run_output(["git", "rev-parse", "--is-inside-work-tree"], check=False) != "true":
|
|
112
|
+
warning("❌ Not inside a git working tree.")
|
|
113
|
+
return 1
|
|
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.")
|
|
118
|
+
return 1
|
|
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.
|
|
128
124
|
if args.dry_run:
|
|
129
|
-
|
|
130
|
-
info(f"🌵 Would run: checkout-my-branches {' '.join(checkout_args)}".rstrip())
|
|
125
|
+
info("🌵 Cached remote refs only; no fetch. Results reflect the last fetch.")
|
|
131
126
|
else:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
127
|
+
fetch_remotes()
|
|
128
|
+
sync = BranchSync(root, args.dry_run)
|
|
129
|
+
sync.update()
|
|
130
|
+
try:
|
|
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."
|
|
139
|
+
)
|
|
140
|
+
return 1
|
|
141
|
+
log("🎉 Preview complete." if args.dry_run else "🎉 Sync complete.")
|
|
135
142
|
return 0
|
|
136
143
|
|
|
137
144
|
|
package/bin/gitutils.py
CHANGED
|
@@ -150,12 +150,12 @@ def worktrees(cwd: str | Path | None = None) -> list[Worktree]:
|
|
|
150
150
|
The first entry is always the main worktree; callers drop it themselves so
|
|
151
151
|
the distinction stays explicit at the call site.
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
DELIBERATE DIVERGENCE: the shell version split on whitespace. NUL-delimited
|
|
154
|
+
porcelain preserves spaces, tabs, backslashes and newlines in paths without
|
|
155
|
+
Git's quoted-path escaping, so commands can route to the exact checkout.
|
|
156
156
|
"""
|
|
157
157
|
found: list[Worktree] = []
|
|
158
|
-
for line in run_output(["git", "worktree", "list", "--porcelain"], cwd=cwd).
|
|
158
|
+
for line in run_output(["git", "worktree", "list", "--porcelain", "-z"], cwd=cwd).split("\0"):
|
|
159
159
|
if line.startswith("worktree "):
|
|
160
160
|
found.append(Worktree(line.removeprefix("worktree "), ""))
|
|
161
161
|
elif line.startswith("branch refs/heads/"):
|
package/package.json
CHANGED
package/shell/che.bash
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# che 1.2.
|
|
1
|
+
# che 1.2.1 - generated file, do not edit.
|
|
2
2
|
#
|
|
3
3
|
# Generated from bin/commands.py by `che install`.
|
|
4
4
|
# To change a wrapper, edit that manifest and run `che install` again.
|
|
@@ -138,13 +138,13 @@ prune-worktrees-dr() {
|
|
|
138
138
|
che_notify 7 $?
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
#
|
|
141
|
+
# Update every branch and worktree, preserving locks and local work
|
|
142
142
|
sync-all-branches() {
|
|
143
143
|
DRY_RUN=false "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" "$@"
|
|
144
144
|
che_notify 7 $?
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
#
|
|
147
|
+
# Update every branch and worktree, preserving locks and local work (preview only)
|
|
148
148
|
sync-all-branches-dr() {
|
|
149
149
|
DRY_RUN=true "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" "$@"
|
|
150
150
|
che_notify 7 $?
|
package/shell/che.fish
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# che 1.2.
|
|
1
|
+
# che 1.2.1 - generated file, do not edit.
|
|
2
2
|
#
|
|
3
3
|
# Generated from bin/commands.py by `che install`.
|
|
4
4
|
# To change a wrapper, edit that manifest and run `che install` again.
|
|
@@ -78,12 +78,12 @@ function prune-worktrees-dr --description 'Remove every linked worktree, then pr
|
|
|
78
78
|
che_notify 7 $status
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
function sync-all-branches --description '
|
|
81
|
+
function sync-all-branches --description 'Update every branch and worktree, preserving locks and local work'
|
|
82
82
|
env DRY_RUN=false "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" $argv
|
|
83
83
|
che_notify 7 $status
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
function sync-all-branches-dr --description '
|
|
86
|
+
function sync-all-branches-dr --description 'Update every branch and worktree, preserving locks and local work (preview only)'
|
|
87
87
|
env DRY_RUN=true "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" $argv
|
|
88
88
|
che_notify 7 $status
|
|
89
89
|
end
|
package/shell/che.zsh
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# che 1.2.
|
|
1
|
+
# che 1.2.1 - generated file, do not edit.
|
|
2
2
|
#
|
|
3
3
|
# Generated from bin/commands.py by `che install`.
|
|
4
4
|
# To change a wrapper, edit that manifest and run `che install` again.
|
|
@@ -144,13 +144,13 @@ prune-worktrees-dr() {
|
|
|
144
144
|
che_notify 7 $?
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
#
|
|
147
|
+
# Update every branch and worktree, preserving locks and local work
|
|
148
148
|
sync-all-branches() {
|
|
149
149
|
DRY_RUN=false "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" "$@"
|
|
150
150
|
che_notify 7 $?
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
#
|
|
153
|
+
# Update every branch and worktree, preserving locks and local work (preview only)
|
|
154
154
|
sync-all-branches-dr() {
|
|
155
155
|
DRY_RUN=true "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" "$@"
|
|
156
156
|
che_notify 7 $?
|
package/shell/completions/_che
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#compdef che
|
|
2
|
-
# che 1.2.
|
|
2
|
+
# che 1.2.1 - generated file, do not edit.
|
|
3
3
|
#
|
|
4
4
|
# Generated from bin/commands.py by `che install`.
|
|
5
5
|
# To change a wrapper, edit that manifest and run `che install` again.
|
|
@@ -85,8 +85,8 @@ _che() {
|
|
|
85
85
|
'clean-stale-branches-dr:Delete local branches whose upstream is gone (preview)'
|
|
86
86
|
'prune-worktrees:Remove every linked worktree, then prune stale admin records'
|
|
87
87
|
'prune-worktrees-dr:Remove every linked worktree, then prune stale admin records (preview)'
|
|
88
|
-
'sync-all-branches:
|
|
89
|
-
'sync-all-branches-dr:
|
|
88
|
+
'sync-all-branches:Update every branch and worktree, preserving locks and local work'
|
|
89
|
+
'sync-all-branches-dr:Update every branch and worktree, preserving locks and local work (preview)'
|
|
90
90
|
'update-from-origin:Alias of sync-all-branches'
|
|
91
91
|
'update-local-branches:Rebase every local branch that has an upstream onto origin'
|
|
92
92
|
'show-codecs:Report media files outside the Direct Play codec/container set'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# che 1.2.
|
|
1
|
+
# che 1.2.1 - generated file, do not edit.
|
|
2
2
|
#
|
|
3
3
|
# Generated from bin/commands.py by `che install`.
|
|
4
4
|
# To change a wrapper, edit that manifest and run `che install` again.
|
|
@@ -11,8 +11,8 @@ complete -c che -n __fish_use_subcommand -a clean-stale-branches -d 'Delete loca
|
|
|
11
11
|
complete -c che -n __fish_use_subcommand -a clean-stale-branches-dr -d 'Delete local branches whose upstream is gone (preview)'
|
|
12
12
|
complete -c che -n __fish_use_subcommand -a prune-worktrees -d 'Remove every linked worktree, then prune stale admin records'
|
|
13
13
|
complete -c che -n __fish_use_subcommand -a prune-worktrees-dr -d 'Remove every linked worktree, then prune stale admin records (preview)'
|
|
14
|
-
complete -c che -n __fish_use_subcommand -a sync-all-branches -d '
|
|
15
|
-
complete -c che -n __fish_use_subcommand -a sync-all-branches-dr -d '
|
|
14
|
+
complete -c che -n __fish_use_subcommand -a sync-all-branches -d 'Update every branch and worktree, preserving locks and local work'
|
|
15
|
+
complete -c che -n __fish_use_subcommand -a sync-all-branches-dr -d 'Update every branch and worktree, preserving locks and local work (preview)'
|
|
16
16
|
complete -c che -n __fish_use_subcommand -a update-from-origin -d 'Alias of sync-all-branches'
|
|
17
17
|
complete -c che -n __fish_use_subcommand -a update-local-branches -d 'Rebase every local branch that has an upstream onto origin'
|
|
18
18
|
complete -c che -n __fish_use_subcommand -a show-codecs -d 'Report media files outside the Direct Play codec/container set'
|