@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 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` | Delete merged branches, rebase the rest, check out new ones; branches only | `sync-all-branches-dr` |
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 # delete merged branches, rebase the rest, check out new ones
367
- sync-all-branches-dr # preview only
368
- update-from-origin # alias of sync-all-branches
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
- Only branches change. The sync deletes local branches whose upstream is gone,
372
- `pull --rebase`s every branch with an upstream, and creates local tracking
373
- branches for the remote branches you authored that aren't local yet, without
374
- switching to them. It ends with the main clone on `main`.
375
-
376
- It **never pushes**, **never touches a worktree** and **never runs the repo's
377
- git hooks**. A branch checked out in a linked worktree is rebased in place
378
- there when that worktree is clean; a stale one is reported and kept, since git
379
- won't delete a branch a worktree has checked out. The branch scripts
380
- (`clean-stale-branches`, `update-local-branches`, `checkout-my-branches`,
381
- `prune-worktrees`, `sync-all-branches`) point `core.hooksPath` at `/dev/null`
382
- for every git they run, so a lefthook or husky pre-commit, pre-push or
383
- post-checkout never fires on a checkout or a rebase.
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="Delete merged branches, rebase the rest, check out new ones; branches only",
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", "prune"),
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, spelled the way sync-all-branches tells its own children;
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
- """🔄 Sync every branch: delete merged ones, rebase the rest, check out new ones.
2
+ """🔄 Update every branch and worktree, keeping checkouts, locks and local work.
3
3
 
4
- Steps, in order:
5
- 1. Switch the main clone to the main branch
6
- 2. Fetch + prune every remote, once for the whole run
7
- 3. clean-stale-branches (delete branches whose upstream is gone)
8
- 4. update-local-branches (rebase every branch onto its upstream)
9
- 5. checkout-my-branches (track your remote branches that aren't local)
10
-
11
- Only branches change. It never pushes, never adds, removes or prunes a
12
- worktree, and never runs a repo's git hooks: pushing and worktrees are the
13
- user's call, and a lefthook or husky pre-commit or pre-push gates the user's
14
- own work, not this bookkeeping. A branch checked out in a linked worktree is
15
- rebased in place there; a stale one is reported, not deleted. The main clone
16
- ends on main: new branches are created without being checked out.
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 Report what would be deleted / synced, change nothing
23
- --author=EMAIL Passed through to checkout-my-branches
24
- --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)
25
16
  --help Show help
26
17
 
27
18
  Env:
28
- 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)
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 gitutils import fetch_remotes, park_main_worktree, skip_git_hooks
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__ = "2.1.0"
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
- The blank line before each one is flushed: a child writes straight to the
65
- shared stdout, so an unflushed separator would surface after its output
66
- whenever the sync is piped.
67
- """
68
- print(flush=True)
69
- run([sys.executable, str(SCRIPT_DIR / script), *args], cwd=cwd)
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="🔄 Delete merged branches, rebase the rest, check out new ones.",
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="Report what would be deleted / synced, change nothing",
88
+ help="Preview cached refs only; no fetch or changes",
82
89
  )
83
- # Accepted and ignored, from when the sync pushed and collapsed worktrees:
84
- # chip's "Update from Origin" still passes --no-push and must keep running.
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="Passed through to checkout-my-branches",
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="Passed through to checkout-my-branches",
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
- # Every later step runs from the main worktree, because the delegated
107
- # scripts expect the main clone. Passing cwd= to each call rather than
108
- # `cd`-ing keeps the process's own directory untouched.
109
- main_wt = park_main_worktree("sync", dry_run=args.dry_run)
110
-
111
- # The one fetch of the run. Each delegated script fetches when run on its
112
- # own; here they inherit the marker fetch_remotes leaves and skip theirs.
113
- fetch_remotes()
114
-
115
- # Dry-run travels as an explicit --dry-run=true|false, never as DRY_RUN in
116
- # the environment, so a child can never disagree with the parent about
117
- # which mode this is - whatever DRY_RUN it inherited.
118
- mode = [f"--dry-run={'true' if args.dry_run else 'false'}"]
119
- delegate("clean-stale-branches.py", mode, cwd=main_wt)
120
- delegate("update-local-branches.py", mode, cwd=main_wt)
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
- print(flush=True)
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
- delegate("checkout-my-branches.py", checkout_args, cwd=main_wt)
133
-
134
- log("🎉 Sync complete.")
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
- The shell version parsed this with ``awk '{print $2}'``, which splits on
154
- whitespace and so truncated any worktree path containing a space. Taking
155
- the whole remainder of the line keeps such paths intact.
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).splitlines():
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codebend3r/che",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Helper scripts shared across machines, called by name from your shell and browsable through one menu",
5
5
  "keywords": [
6
6
  "cli",
package/shell/che.bash CHANGED
@@ -1,4 +1,4 @@
1
- # che 1.2.0 - generated file, do not edit.
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
- # Delete merged branches, rebase the rest, check out new ones; branches only
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
- # Delete merged branches, rebase the rest, check out new ones; branches only (preview only)
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.0 - generated file, do not edit.
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 'Delete merged branches, rebase the rest, check out new ones; branches only'
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 'Delete merged branches, rebase the rest, check out new ones; branches only (preview only)'
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.0 - generated file, do not edit.
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
- # Delete merged branches, rebase the rest, check out new ones; branches only
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
- # Delete merged branches, rebase the rest, check out new ones; branches only (preview only)
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 $?
@@ -1,5 +1,5 @@
1
1
  #compdef che
2
- # che 1.2.0 - generated file, do not edit.
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:Delete merged branches, rebase the rest, check out new ones; branches only'
89
- 'sync-all-branches-dr:Delete merged branches, rebase the rest, check out new ones; branches only (preview)'
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.0 - generated file, do not edit.
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.
@@ -1,4 +1,4 @@
1
- # che 1.2.0 - generated file, do not edit.
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 'Delete merged branches, rebase the rest, check out new ones; branches only'
15
- complete -c che -n __fish_use_subcommand -a sync-all-branches-dr -d 'Delete merged branches, rebase the rest, check out new ones; branches only (preview)'
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'