@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 +15 -4
- package/bin/branchsync.py +258 -11
- package/bin/commands.py +1 -1
- package/bin/git/prune-worktrees.py +51 -13
- package/bin/git/sync-all-branches.py +14 -9
- package/bin/gitutils.py +3 -0
- 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
|
@@ -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,
|
|
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
|
-
- **
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
"""
|
|
1
|
+
"""Branch/worktree synchronization for sync-all-branches.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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",
|
|
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
|
-
|
|
211
|
-
f"
|
|
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,
|
|
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,
|
|
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
|
|
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.
|
|
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="
|
|
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 =
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 {
|
|
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,
|
|
2
|
+
"""🔄 Update every branch and worktree, and prune what has been merged.
|
|
3
3
|
|
|
4
|
-
Fetch once,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
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,
|
|
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
|
|
123
|
-
# branch merely because its remote was deleted
|
|
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
package/shell/che.bash
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# che 1.2.
|
|
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,
|
|
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,
|
|
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.
|
|
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,
|
|
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,
|
|
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.
|
|
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,
|
|
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,
|
|
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 $?
|
package/shell/completions/_che
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#compdef che
|
|
2
|
-
# che 1.2.
|
|
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,
|
|
93
|
-
'sync-all-branches-dr:Update every branch and worktree,
|
|
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.
|
|
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,
|
|
17
|
-
complete -c che -n __fish_use_subcommand -a sync-all-branches-dr -d 'Update every branch and worktree,
|
|
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'
|