@codebend3r/che 1.2.2 → 1.2.3

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
@@ -257,7 +257,7 @@ All scripts share the same CLI conventions:
257
257
  | `clean-stale-branches` | Delete local branches whose upstream is gone | `clean-stale-branches-dr` |
258
258
  | `hard-reset-all-branches` | Check out every local branch and hard reset it to origin/<branch> | `hard-reset-all-branches-dr` |
259
259
  | `prune-worktrees` | Remove every linked worktree, then prune stale admin records | `prune-worktrees-dr` |
260
- | `sync-all-branches` | Update every branch and worktree, preserving locks and local work | `sync-all-branches-dr` |
260
+ | `sync-all-branches` | Update every branch and worktree, pruning what has been merged | `sync-all-branches-dr` |
261
261
  | `update-from-origin` | Alias of sync-all-branches | `--dry-run` |
262
262
  | `update-local-branches` | Rebase every local branch that has an upstream onto origin | `--dry-run` |
263
263
  | **Video** | *Codecs, renaming, dedupe and metadata* | |
@@ -337,9 +337,20 @@ sync-all-branches-dr # preview against cached remote refs; no fetch, no chang
337
337
  stashed and restored with their staged state; existing stashes are
338
338
  untouched. Dirty submodules and in-progress Git operations are reported and
339
339
  left alone.
340
- - **Nothing is destroyed.** It never pushes, hard resets or deletes branches.
341
- Missing upstreams are reported, not treated as merged; use
342
- `clean-stale-branches` for cleanup.
340
+ - **No local change is discarded.** It never pushes. A branch is only moved
341
+ or deleted when Git can prove its changes already exist elsewhere:
342
+ - A diverged branch whose changes are all in its upstream (a stale copy of
343
+ a branch rebased and force-pushed from another machine, typically after
344
+ its parent was squash-merged) moves to the upstream instead of replaying
345
+ commits that would conflict.
346
+ - A branch whose upstream is gone is deleted, with its clean worktree (a
347
+ lock is dropped first), once it is proven merged into `origin/main`,
348
+ `master`, `dev` or `develop`: contained in it, its content already there
349
+ (squash merges included), or a merged GitHub PR. The tip SHA is printed so
350
+ you can restore it. A missing upstream alone is not treated as merged: an
351
+ unproven one is listed as `Upstream gone` and kept. A merged branch whose
352
+ worktree is dirty, mid-operation or your current checkout is kept and
353
+ reported.
343
354
  - **Failures are isolated.** A rebase conflict is aborted and reported while
344
355
  other branches continue, and any blocked branch makes the command exit
345
356
  non-zero. If restoring work conflicts, the recovery stash ID and worktree
package/bin/branchsync.py CHANGED
@@ -1,20 +1,38 @@
1
- """Non-destructive branch/worktree synchronization for sync-all-branches.
1
+ """Branch/worktree synchronization for sync-all-branches.
2
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.
3
+ Sync rebases every branch onto its upstream inside its own checkout, locked or
4
+ not, and keeps every local change. A branch is only moved past its own commits
5
+ (reset to an upstream that already holds them) or deleted when a content check
6
+ proves its changes are already upstream or in a main or dev branch. The only
7
+ things it deletes are disposable worktrees it created itself and, with a merged
8
+ branch whose upstream is gone, that branch's clean worktree.
6
9
  """
7
10
 
8
11
  from __future__ import annotations
9
12
 
13
+ import json
14
+ import shutil
10
15
  import subprocess
11
16
  import tempfile
12
17
  from dataclasses import dataclass, field
18
+ from datetime import datetime
13
19
  from pathlib import Path
14
20
 
15
21
  from gitutils import Worktree, local_branches, worktrees
16
22
  from utils import info, run, run_output, success, warning
17
23
 
24
+ # A branch is "merged" when it reached one of these on origin. They are also
25
+ # never pruned themselves, whatever state their upstream is in.
26
+ BASE_BRANCHES = ("main", "master", "dev", "develop")
27
+ PROTECTED_BRANCHES = (*BASE_BRANCHES, "staging")
28
+
29
+ _PROBE_IDENTITY = {
30
+ "GIT_AUTHOR_NAME": "che",
31
+ "GIT_AUTHOR_EMAIL": "che@localhost",
32
+ "GIT_COMMITTER_NAME": "che",
33
+ "GIT_COMMITTER_EMAIL": "che@localhost",
34
+ }
35
+
18
36
  _OPERATION_FILES = (
19
37
  "rebase-merge",
20
38
  "rebase-apply",
@@ -50,7 +68,11 @@ class BranchSync:
50
68
  updated: list[str] = field(default_factory=list)
51
69
  current: list[str] = field(default_factory=list)
52
70
  local_only: list[str] = field(default_factory=list)
71
+ gone: list[str] = field(default_factory=list)
53
72
  restored: list[str] = field(default_factory=list)
73
+ reset: list[str] = field(default_factory=list)
74
+ deleted: list[str] = field(default_factory=list)
75
+ merged_kept: list[str] = field(default_factory=list)
54
76
  blocked: list[str] = field(default_factory=list)
55
77
  trees: list[Worktree] = field(init=False)
56
78
 
@@ -116,8 +138,11 @@ class BranchSync:
116
138
  )
117
139
 
118
140
  def includes(self, branch: str, upstream: str) -> bool:
141
+ return self.is_ancestor(upstream, f"refs/heads/{branch}")
142
+
143
+ def is_ancestor(self, older: str, newer: str) -> bool:
119
144
  result = run(
120
- ["git", "merge-base", "--is-ancestor", upstream, f"refs/heads/{branch}"],
145
+ ["git", "merge-base", "--is-ancestor", older, newer],
121
146
  cwd=self.root,
122
147
  check=False,
123
148
  capture=True,
@@ -128,6 +153,33 @@ class BranchSync:
128
153
  )
129
154
  return result.returncode == 0
130
155
 
156
+ def adds_nothing(self, branch: str, target: str) -> bool:
157
+ """True when merging ``branch`` into ``target`` would leave its tree unchanged.
158
+
159
+ Decided by content, not ancestry: a squash-merged branch, or a stale copy
160
+ of an upstream that was rebased and force-pushed elsewhere, shares no
161
+ commits with ``target`` even though every change it carries is already
162
+ there. A conflict, unrelated histories, or a git older than 2.38 (no
163
+ ``--write-tree``) all answer False, so doubt never discards anything.
164
+ """
165
+ merged = run(
166
+ ["git", "merge-tree", "--write-tree", target, f"refs/heads/{branch}"],
167
+ cwd=self.root,
168
+ check=False,
169
+ capture=True,
170
+ )
171
+ if merged.returncode or not merged.stdout:
172
+ return False
173
+ tree = merged.stdout.splitlines()[0]
174
+ return tree == run_output(["git", "rev-parse", f"{target}^{{tree}}"], cwd=self.root)
175
+
176
+ def keep_gone(self, branch: str, upstream: str) -> None:
177
+ # Usually a merged PR whose remote branch was deleted, or one closed
178
+ # unmerged. Nothing is left to pull, so it is not a failure; keep it
179
+ # for the user to review.
180
+ info(f" 🪦 Upstream gone: {branch} ({upstream}); branch and worktree retained")
181
+ self.gone.append(branch)
182
+
131
183
  def save_wip(self, branch: str, path: Path) -> str:
132
184
  if not self.dirty(path):
133
185
  return ""
@@ -206,10 +258,21 @@ class BranchSync:
206
258
  info(f" = Up to date: {branch} (local commits kept){f' in {path}' if path else ''}")
207
259
  self.current.append(branch)
208
260
  return
261
+ # Replaying commits whose content the upstream already holds conflicts
262
+ # for nothing: the usual cause is a parent branch squash-merged into main
263
+ # and this branch rebased and force-pushed from another machine. When a
264
+ # diverged branch adds nothing to its upstream, moving it there discards
265
+ # no change. A branch merely behind fast-forwards through the rebase.
266
+ stale = not self.is_ancestor(f"refs/heads/{branch}", upstream) and self.adds_nothing(
267
+ branch, upstream
268
+ )
209
269
  if self.dry_run:
210
- info(
211
- f" 🌵 Would update {branch} from {upstream}{f' in {path}' if path else ''}; stash/restore WIP if needed"
270
+ action = (
271
+ f"reset {branch} to {upstream} (its changes are already there)"
272
+ if stale
273
+ else f"update {branch} from {upstream}"
212
274
  )
275
+ info(f" 🌵 Would {action}{f' in {path}' if path else ''}; stash/restore WIP if needed")
213
276
  return
214
277
 
215
278
  temporary: Path | None = None
@@ -220,6 +283,29 @@ class BranchSync:
220
283
  path = temporary / "checkout"
221
284
  run(["git", "worktree", "add", "--quiet", str(path), branch], cwd=self.root)
222
285
  oid = self.save_wip(branch, path)
286
+ if stale:
287
+ info(
288
+ f"⏩ Resetting {branch} to {upstream} in {path}; its changes are already there"
289
+ )
290
+ # --keep, not --hard: WIP is already stashed, and --keep refuses
291
+ # rather than overwrite anything that slipped past the stash.
292
+ moved = (
293
+ run(
294
+ ["git", "reset", "--quiet", "--keep", upstream], cwd=path, check=False
295
+ ).returncode
296
+ == 0
297
+ )
298
+ if not moved:
299
+ warning(f" ❌ Reset failed for {branch} in {path}")
300
+ self.block(branch, f"reset failed in {path}; original commits retained")
301
+ restored = self.restore_wip(branch, path, oid)
302
+ if moved and restored:
303
+ if self.includes(branch, upstream):
304
+ self.reset.append(branch)
305
+ success(f" ✅ Reset to upstream: {branch}")
306
+ else:
307
+ self.block(branch, "verification failed: fetched upstream not included")
308
+ return
223
309
  info(f"⬇️ Rebasing {branch} in {path} (lock preserved)")
224
310
  # DELIBERATE DIVERGENCE: one fetched snapshot instead of pull's
225
311
  # per-branch fetch. Disable autostash/updateRefs so Git cannot
@@ -289,6 +375,159 @@ class BranchSync:
289
375
  f"temporary worktree retained for inspection: {temporary / 'checkout'}",
290
376
  )
291
377
 
378
+ def bases(self) -> list[str]:
379
+ return [ref for name in BASE_BRANCHES if self.has_ref(ref := f"refs/remotes/origin/{name}")]
380
+
381
+ def merged_reason(self, branch: str) -> str:
382
+ """How ``branch`` reached a main or dev branch on origin, or "" if unproven.
383
+
384
+ Four proofs, cheapest first: the tip is contained in the base; merging it
385
+ would leave the base's tree unchanged (``adds_nothing``, a squash merge
386
+ or a stale copy of rebased work); a one-commit probe of the branch's net
387
+ change is already in the base by patch ID (a squash merge whose lines
388
+ the base has since changed again, which ``adds_nothing`` sees as a
389
+ conflict); or GitHub reports a merged PR whose head is the tip, with no
390
+ local commit newer than the merge (covers stacked PRs rebased and
391
+ force-pushed before a squash).
392
+ """
393
+ tip = run_output(["git", "rev-parse", "--verify", f"refs/heads/{branch}"], cwd=self.root)
394
+ for base in self.bases():
395
+ label = base.removeprefix("refs/remotes/")
396
+ if (
397
+ run(
398
+ ["git", "merge-base", "--is-ancestor", tip, base],
399
+ cwd=self.root,
400
+ check=False,
401
+ capture=True,
402
+ ).returncode
403
+ == 0
404
+ ):
405
+ return f"contained in {label}"
406
+ if self.adds_nothing(branch, base):
407
+ return f"its content is already in {label}"
408
+ fork = run_output(["git", "merge-base", base, tip], cwd=self.root, check=False)
409
+ if not fork:
410
+ continue
411
+ probe = run_output(
412
+ ["git", "commit-tree", f"{tip}^{{tree}}", "-p", fork, "-m", "che squash probe"],
413
+ cwd=self.root,
414
+ check=False,
415
+ )
416
+ if probe:
417
+ cherry = run_output(["git", "cherry", base, probe], cwd=self.root, check=False)
418
+ if cherry.startswith("-"):
419
+ return f"squash-merged into {label}"
420
+ return self.merged_pull_request(branch, tip)
421
+
422
+ def merged_pull_request(self, branch: str, tip: str) -> str:
423
+ origin = run_output(["git", "remote", "get-url", "origin"], cwd=self.root, check=False)
424
+ # The patch-ID proofs work from cached refs; this one is a network call.
425
+ if self.dry_run or "github.com" not in origin or shutil.which("gh") is None:
426
+ return ""
427
+ listing = run(
428
+ [
429
+ "gh",
430
+ "pr",
431
+ "list",
432
+ "--state",
433
+ "merged",
434
+ "--head",
435
+ branch,
436
+ "--json",
437
+ "number,headRefOid,mergedAt",
438
+ ],
439
+ cwd=self.root,
440
+ check=False,
441
+ capture=True,
442
+ )
443
+ if listing.returncode:
444
+ return ""
445
+ try:
446
+ pulls = json.loads(listing.stdout or "[]")
447
+ except json.JSONDecodeError:
448
+ return ""
449
+ tip_time = int(run_output(["git", "log", "-1", "--format=%ct", tip], cwd=self.root))
450
+ for pull in pulls:
451
+ contains = (
452
+ run(
453
+ ["git", "merge-base", "--is-ancestor", tip, pull["headRefOid"]],
454
+ cwd=self.root,
455
+ check=False,
456
+ capture=True,
457
+ ).returncode
458
+ == 0
459
+ )
460
+ merged_at = datetime.fromisoformat(pull["mergedAt"]).timestamp()
461
+ if contains or tip_time <= merged_at:
462
+ return f"PR #{pull['number']} merged"
463
+ return ""
464
+
465
+ def keep_worktree(self, tree: Worktree) -> str:
466
+ """Why a merged branch's worktree must stay, or "" when it can go."""
467
+ path = Path(tree.path)
468
+ if path.resolve() in (self.root.resolve(), Path(self.trees[0].path).resolve()):
469
+ return "checked out here"
470
+ if not path.is_dir():
471
+ return ""
472
+ if in_progress(path):
473
+ return f"operation in progress in {path}"
474
+ if self.dirty(path):
475
+ return f"uncommitted work in {path}"
476
+ return ""
477
+
478
+ def drop_gone(self, branch: str, upstream: str, occupied: dict[str, Path]) -> None:
479
+ """Delete ``branch`` and its clean worktree when merged; otherwise keep it.
480
+
481
+ DELIBERATE DIVERGENCE: a gone upstream used to be "not proof of a
482
+ merge" and was only reported. It is still not proof (a PR can be closed
483
+ unmerged), but it is the trigger, and the merge is proven separately by
484
+ :meth:`merged_reason`; an unproven branch is kept and reported as
485
+ `Upstream gone`, not as a failure. A merged branch's worktree is removed
486
+ with it when clean, and a lock is dropped first, since the branch is
487
+ finished; a dirty, in-progress or caller checkout is kept.
488
+ """
489
+ if branch in PROTECTED_BRANCHES:
490
+ info(f" 🛡️ Protected: {branch} (upstream gone)")
491
+ return
492
+ reason = self.merged_reason(branch)
493
+ if not reason:
494
+ self.keep_gone(branch, upstream)
495
+ return
496
+ tip = run_output(["git", "rev-parse", "--short", f"refs/heads/{branch}"], cwd=self.root)
497
+ path = occupied.get(branch)
498
+ tree = next((t for t in self.trees if path and Path(t.path) == path), None)
499
+ if tree is not None and (why := self.keep_worktree(tree)):
500
+ info(f" ⏭️ Merged, kept: {branch} ({reason}; {why})")
501
+ self.merged_kept.append(f"{branch}: {why}")
502
+ return
503
+ if self.dry_run:
504
+ info(f" 🌵 Would delete merged: {branch} at {tip} ({reason})")
505
+ return
506
+ if tree is not None:
507
+ if tree.locked:
508
+ run(["git", "worktree", "unlock", tree.path], cwd=self.root, check=False)
509
+ removed = run(
510
+ [
511
+ "git",
512
+ "worktree",
513
+ "remove",
514
+ *(["--force"] if not Path(tree.path).is_dir() else []),
515
+ tree.path,
516
+ ],
517
+ cwd=self.root,
518
+ check=False,
519
+ )
520
+ if removed.returncode:
521
+ self.block(branch, f"merged ({reason}) but worktree removal refused: {tree.path}")
522
+ return
523
+ if run(["git", "branch", "-D", branch], cwd=self.root, check=False).returncode:
524
+ self.block(branch, f"merged ({reason}) but branch deletion failed")
525
+ return
526
+ self.deleted.append(branch)
527
+ success(
528
+ f" 🗑️ Deleted merged: {branch} at {tip} ({reason}) - restore with: git branch {branch} {tip}"
529
+ )
530
+
292
531
  def update(self) -> None:
293
532
  occupied = self.occupied()
294
533
  for branch, upstream in local_branches("upstream", cwd=self.root):
@@ -298,10 +537,10 @@ class BranchSync:
298
537
  info(f" ℹ️ Local only: {branch} (no remote counterpart)") # noqa: RUF001
299
538
  self.local_only.append(branch)
300
539
  continue
301
- if not self.has_ref(upstream):
302
- self.block(branch, f"upstream gone: {upstream}; branch and worktree retained")
303
- continue
304
540
  try:
541
+ if not self.has_ref(upstream):
542
+ self.drop_gone(branch, upstream, occupied)
543
+ continue
305
544
  self.sync_branch(branch, upstream, occupied)
306
545
  except (subprocess.CalledProcessError, OSError) as exc:
307
546
  self.block(branch, f"preflight failed: {exc}")
@@ -310,15 +549,23 @@ class BranchSync:
310
549
  success("═══ summary ═══")
311
550
  for label, names in (
312
551
  ("Updated", self.updated),
552
+ ("Reset to upstream", self.reset),
313
553
  ("Up to date", self.current),
554
+ ("Deleted (merged)", self.deleted),
314
555
  ("Tracked new", tracked),
315
556
  ("Local only", self.local_only),
557
+ ("Upstream gone", self.gone),
316
558
  ("WIP restored", self.restored),
317
559
  ):
318
560
  info(f"{label}: {len(names)} ({', '.join(names)})")
561
+ if self.gone:
562
+ info(" Review them; clean-stale-branches removes them once you are sure.")
563
+ info(f"Merged, kept: {len(self.merged_kept)}")
564
+ for reason in self.merged_kept:
565
+ info(f" • {reason}")
319
566
  info(f"Blocked: {len(self.blocked)}")
320
567
  for reason in self.blocked:
321
568
  warning(f" • {reason}")
322
569
  info(
323
- "Caller checkout, existing worktree paths, locks and local commits preserved. Nothing pushed."
570
+ "Caller checkout, unmerged branches, dirty worktrees and every local change preserved. Nothing pushed."
324
571
  )
package/bin/commands.py CHANGED
@@ -282,7 +282,7 @@ COMMANDS: tuple[Command, ...] = (
282
282
  flags=("--dry-run", "--author=", "--limit="),
283
283
  category="git",
284
284
  icon="🔄",
285
- summary="Update every branch and worktree, preserving locks and local work",
285
+ summary="Update every branch and worktree, pruning what has been merged",
286
286
  script="git/sync-all-branches.py",
287
287
  sound=7,
288
288
  dry_run="env",
@@ -1,14 +1,18 @@
1
1
  #!/usr/bin/env python3
2
2
  """🌿 Remove every linked worktree in the current repository, then prune records.
3
3
 
4
- The main worktree (the one containing .git) is always left untouched.
4
+ The main worktree (the one containing .git) is always left untouched. A locked
5
+ worktree is unlocked first, since a lock only guards against an accidental
6
+ removal and this command is the deliberate one. A worktree with uncommitted
7
+ changes is kept, lock and all, unless --force is given; one failure never stops
8
+ the rest.
5
9
 
6
10
  Usage:
7
11
  prune-worktrees.py [--dry-run] [--force]
8
12
 
9
13
  Options:
10
14
  --dry-run Show what would be removed, change nothing
11
- --force Pass --force to 'git worktree remove' (drops dirty trees)
15
+ --force Remove worktrees with uncommitted changes too
12
16
  --help Show help
13
17
 
14
18
  Env:
@@ -22,7 +26,7 @@ from pathlib import Path
22
26
 
23
27
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
24
28
 
25
- from gitutils import park_main_worktree, skip_git_hooks, worktrees
29
+ from gitutils import park_main_worktree, skip_git_hooks, worktree_is_clean, worktrees
26
30
  from utils import (
27
31
  add_bool_flag,
28
32
  build_parser,
@@ -37,7 +41,7 @@ from utils import (
37
41
  warning,
38
42
  )
39
43
 
40
- __version__ = "1.3.0"
44
+ __version__ = "1.4.0"
41
45
 
42
46
 
43
47
  def main(argv: list[str] | None = None) -> int:
@@ -54,7 +58,7 @@ def main(argv: list[str] | None = None) -> int:
54
58
  add_bool_flag(
55
59
  parser,
56
60
  "--force",
57
- help="Pass --force to 'git worktree remove' (drops dirty trees)",
61
+ help="Remove worktrees with uncommitted changes too",
58
62
  )
59
63
  args = parser.parse_args(argv)
60
64
 
@@ -69,7 +73,7 @@ def main(argv: list[str] | None = None) -> int:
69
73
  main_wt = park_main_worktree("prune", dry_run=args.dry_run)
70
74
 
71
75
  info("🔍 Listing worktrees...")
72
- linked = [worktree.path for worktree in worktrees(main_wt)[1:]]
76
+ linked = worktrees(main_wt)[1:]
73
77
 
74
78
  if not linked:
75
79
  success("✨ No linked worktrees. Running prune anyway... 🎉")
@@ -83,20 +87,54 @@ def main(argv: list[str] | None = None) -> int:
83
87
 
84
88
  if args.dry_run:
85
89
  warning(f"🌵 {len(linked)} worktree(s) would be removed:")
86
- for path in linked:
87
- warning(f" • 🪦 {path}")
90
+ for worktree in linked:
91
+ note = " (locked; would unlock first)" if worktree.locked else ""
92
+ warning(f" • 🪦 {worktree.path}{note}")
88
93
  info("📜 Would also run: git worktree prune --verbose")
89
94
  dry_run_complete("removed")
90
95
  return 0
91
96
 
92
- remove_args = ["--force"] if args.force else []
93
- for path in linked:
94
- warning(f"🗑️ Removing: {path}")
95
- run(["git", "worktree", "remove", *remove_args, path], cwd=main_wt)
97
+ removed = 0
98
+ kept: list[str] = []
99
+ for worktree in linked:
100
+ warning(f"🗑️ Removing: {worktree.path}")
101
+ # DELIBERATE DIVERGENCE: a lock used to abort the whole run with git's
102
+ # "cannot remove a locked working tree". Unlock, but only once the
103
+ # removal is certain to be allowed, so a kept worktree keeps its lock.
104
+ # A checkout whose directory is gone has no work left to lose.
105
+ gone = not Path(worktree.path).is_dir()
106
+ if not (args.force or gone) and not worktree_is_clean(worktree.path):
107
+ warning(" ⏭️ Uncommitted changes; kept (use --force to remove)")
108
+ kept.append(worktree.path)
109
+ continue
110
+ if worktree.locked:
111
+ info(" 🔓 Unlocking")
112
+ run(["git", "worktree", "unlock", worktree.path], cwd=main_wt, check=False)
113
+ removal = run(
114
+ [
115
+ "git",
116
+ "worktree",
117
+ "remove",
118
+ *(["--force"] if args.force or gone else []),
119
+ worktree.path,
120
+ ],
121
+ cwd=main_wt,
122
+ check=False,
123
+ )
124
+ if removal.returncode:
125
+ warning(f" ❌ Could not remove: {worktree.path}")
126
+ kept.append(worktree.path)
127
+ else:
128
+ removed += 1
96
129
 
97
130
  info("🧹 Pruning stale worktree records...")
98
131
  run(["git", "worktree", "prune", "--verbose"], cwd=main_wt)
99
- success(f"✨ Removed {len(linked)} worktree(s). 🎉")
132
+ success(f"✨ Removed {removed} worktree(s). 🎉")
133
+ if kept:
134
+ warning(f"⚠️ {len(kept)} worktree(s) kept:")
135
+ for path in kept:
136
+ warning(f" • {path}")
137
+ return 1
100
138
  return 0
101
139
 
102
140
 
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env python3
2
- """🔄 Update every branch and worktree, keeping checkouts, locks and local work.
2
+ """🔄 Update every branch and worktree, and prune what has been merged.
3
3
 
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.
4
+ Fetch once, delete branches (and their clean worktrees) that were merged into a
5
+ main or dev branch and whose upstream is gone, rebase the rest onto their
6
+ upstream (or origin/<branch>), and track new origin branches authored by you.
7
+ Worktrees stay worktrees and are updated in place. A branch whose changes are
8
+ all already upstream moves to its upstream instead of replaying them. Dirty
9
+ tracked/untracked work is saved in an exact recovery stash and restored with
10
+ its index. Conflicts are reported; independent branches continue. Nothing is
11
+ pushed. No local change is ever discarded.
8
12
 
9
13
  Usage:
10
14
  sync-all-branches.py [--dry-run] [--author=EMAIL] [--limit=N]
@@ -45,7 +49,7 @@ from utils import (
45
49
  warning,
46
50
  )
47
51
 
48
- __version__ = "3.0.0"
52
+ __version__ = "3.2.0"
49
53
 
50
54
 
51
55
  def track_new(sync: BranchSync, author: str, limit: int) -> list[str]:
@@ -79,7 +83,7 @@ def track_new(sync: BranchSync, author: str, limit: int) -> list[str]:
79
83
  def main(argv: list[str] | None = None) -> int:
80
84
  parser = build_parser(
81
85
  prog="sync-all-branches",
82
- description="🔄 Update every branch and worktree, preserving locks and local work.",
86
+ description="🔄 Update every branch and worktree, and prune what has been merged.",
83
87
  )
84
88
  add_bool_flag(
85
89
  parser,
@@ -119,8 +123,9 @@ def main(argv: list[str] | None = None) -> int:
119
123
  original = current_branch(root) or run_output(["git", "rev-parse", "--short", "HEAD"], cwd=root)
120
124
  info(f"📍 Keeping caller checkout: {original}")
121
125
  # 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.
126
+ # Preserve active workspaces instead, and never delete a gone-upstream
127
+ # branch merely because its remote was deleted: only once it is proven
128
+ # merged (BranchSync.drop_gone). Dry-run must not fetch.
124
129
  if args.dry_run:
125
130
  info("🌵 Cached remote refs only; no fetch. Results reflect the last fetch.")
126
131
  else:
package/bin/gitutils.py CHANGED
@@ -142,6 +142,7 @@ def local_branches(*fields: str, sort: str = "", cwd: str | Path | None = None)
142
142
  class Worktree(NamedTuple):
143
143
  path: str
144
144
  branch: str # "" when detached (or bare): it pins no branch
145
+ locked: bool = False # `git worktree lock`: git refuses to remove or prune it
145
146
 
146
147
 
147
148
  def worktrees(cwd: str | Path | None = None) -> list[Worktree]:
@@ -162,6 +163,8 @@ def worktrees(cwd: str | Path | None = None) -> list[Worktree]:
162
163
  # Every record opens with its `worktree` line, so this belongs to
163
164
  # the last one appended.
164
165
  found[-1] = found[-1]._replace(branch=line.removeprefix("branch refs/heads/"))
166
+ elif line == "locked" or line.startswith("locked "):
167
+ found[-1] = found[-1]._replace(locked=True)
165
168
  return found
166
169
 
167
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codebend3r/che",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
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.2 - generated file, do not edit.
1
+ # che 1.2.3 - 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.
@@ -151,13 +151,13 @@ prune-worktrees-dr() {
151
151
  che_notify 7 $?
152
152
  }
153
153
 
154
- # Update every branch and worktree, preserving locks and local work
154
+ # Update every branch and worktree, pruning what has been merged
155
155
  sync-all-branches() {
156
156
  DRY_RUN=false "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" "$@"
157
157
  che_notify 7 $?
158
158
  }
159
159
 
160
- # Update every branch and worktree, preserving locks and local work (preview only)
160
+ # Update every branch and worktree, pruning what has been merged (preview only)
161
161
  sync-all-branches-dr() {
162
162
  DRY_RUN=true "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" "$@"
163
163
  che_notify 7 $?
package/shell/che.fish CHANGED
@@ -1,4 +1,4 @@
1
- # che 1.2.2 - generated file, do not edit.
1
+ # che 1.2.3 - 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.
@@ -88,12 +88,12 @@ function prune-worktrees-dr --description 'Remove every linked worktree, then pr
88
88
  che_notify 7 $status
89
89
  end
90
90
 
91
- function sync-all-branches --description 'Update every branch and worktree, preserving locks and local work'
91
+ function sync-all-branches --description 'Update every branch and worktree, pruning what has been merged'
92
92
  env DRY_RUN=false "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" $argv
93
93
  che_notify 7 $status
94
94
  end
95
95
 
96
- function sync-all-branches-dr --description 'Update every branch and worktree, preserving locks and local work (preview only)'
96
+ function sync-all-branches-dr --description 'Update every branch and worktree, pruning what has been merged (preview only)'
97
97
  env DRY_RUN=true "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" $argv
98
98
  che_notify 7 $status
99
99
  end
package/shell/che.zsh CHANGED
@@ -1,4 +1,4 @@
1
- # che 1.2.2 - generated file, do not edit.
1
+ # che 1.2.3 - 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.
@@ -157,13 +157,13 @@ prune-worktrees-dr() {
157
157
  che_notify 7 $?
158
158
  }
159
159
 
160
- # Update every branch and worktree, preserving locks and local work
160
+ # Update every branch and worktree, pruning what has been merged
161
161
  sync-all-branches() {
162
162
  DRY_RUN=false "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" "$@"
163
163
  che_notify 7 $?
164
164
  }
165
165
 
166
- # Update every branch and worktree, preserving locks and local work (preview only)
166
+ # Update every branch and worktree, pruning what has been merged (preview only)
167
167
  sync-all-branches-dr() {
168
168
  DRY_RUN=true "$CHE_PYTHON" "$CHE_BIN/git/sync-all-branches.py" "$@"
169
169
  che_notify 7 $?
@@ -1,5 +1,5 @@
1
1
  #compdef che
2
- # che 1.2.2 - generated file, do not edit.
2
+ # che 1.2.3 - 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.
@@ -89,8 +89,8 @@ _che() {
89
89
  'hard-reset-all-branches-dr:Check out every local branch and hard reset it to origin/<branch> (preview)'
90
90
  'prune-worktrees:Remove every linked worktree, then prune stale admin records'
91
91
  'prune-worktrees-dr:Remove every linked worktree, then prune stale admin records (preview)'
92
- 'sync-all-branches:Update every branch and worktree, preserving locks and local work'
93
- 'sync-all-branches-dr:Update every branch and worktree, preserving locks and local work (preview)'
92
+ 'sync-all-branches:Update every branch and worktree, pruning what has been merged'
93
+ 'sync-all-branches-dr:Update every branch and worktree, pruning what has been merged (preview)'
94
94
  'update-from-origin:Alias of sync-all-branches'
95
95
  'update-local-branches:Rebase every local branch that has an upstream onto origin'
96
96
  'show-codecs:Report media files outside the Direct Play codec/container set'
@@ -1,4 +1,4 @@
1
- # che 1.2.2 - generated file, do not edit.
1
+ # che 1.2.3 - 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.2 - generated file, do not edit.
1
+ # che 1.2.3 - 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.
@@ -13,8 +13,8 @@ complete -c che -n __fish_use_subcommand -a hard-reset-all-branches -d 'Check ou
13
13
  complete -c che -n __fish_use_subcommand -a hard-reset-all-branches-dr -d 'Check out every local branch and hard reset it to origin/<branch> (preview)'
14
14
  complete -c che -n __fish_use_subcommand -a prune-worktrees -d 'Remove every linked worktree, then prune stale admin records'
15
15
  complete -c che -n __fish_use_subcommand -a prune-worktrees-dr -d 'Remove every linked worktree, then prune stale admin records (preview)'
16
- complete -c che -n __fish_use_subcommand -a sync-all-branches -d 'Update every branch and worktree, preserving locks and local work'
17
- 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
+ complete -c che -n __fish_use_subcommand -a sync-all-branches -d 'Update every branch and worktree, pruning what has been merged'
17
+ complete -c che -n __fish_use_subcommand -a sync-all-branches-dr -d 'Update every branch and worktree, pruning what has been merged (preview)'
18
18
  complete -c che -n __fish_use_subcommand -a update-from-origin -d 'Alias of sync-all-branches'
19
19
  complete -c che -n __fish_use_subcommand -a update-local-branches -d 'Rebase every local branch that has an upstream onto origin'
20
20
  complete -c che -n __fish_use_subcommand -a show-codecs -d 'Report media files outside the Direct Play codec/container set'