@codebend3r/che 1.2.1 โ 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 +271 -249
- package/bin/branchsync.py +258 -11
- package/bin/commands.py +15 -1
- package/bin/git/hard-reset-all-branches.py +250 -0
- 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 +17 -4
- package/shell/che.fish +13 -3
- package/shell/che.zsh +17 -4
- package/shell/completions/_che +8 -4
- package/shell/completions/che.bash +5 -3
- package/shell/completions/che.fish +13 -3
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
|
@@ -250,6 +250,20 @@ COMMANDS: tuple[Command, ...] = (
|
|
|
250
250
|
tags=("branch", "delete", "prune"),
|
|
251
251
|
prompts=(Prompt("--protect", "Extra branches to protect", help="Comma separated"),),
|
|
252
252
|
),
|
|
253
|
+
Command(
|
|
254
|
+
name="hard-reset-all-branches",
|
|
255
|
+
flags=("--dry-run", "--force"),
|
|
256
|
+
category="git",
|
|
257
|
+
icon="๐ฃ",
|
|
258
|
+
summary="Check out every local branch and hard reset it to origin/<branch>",
|
|
259
|
+
script="git/hard-reset-all-branches.py",
|
|
260
|
+
sound=4,
|
|
261
|
+
dry_run="env",
|
|
262
|
+
destructive=True,
|
|
263
|
+
needs=("git",),
|
|
264
|
+
tags=("branch", "reset", "origin", "worktree"),
|
|
265
|
+
prompts=(Prompt("--force", "Also discard uncommitted changes", kind="bool"),),
|
|
266
|
+
),
|
|
253
267
|
Command(
|
|
254
268
|
name="prune-worktrees",
|
|
255
269
|
flags=("--dry-run", "--force"),
|
|
@@ -268,7 +282,7 @@ COMMANDS: tuple[Command, ...] = (
|
|
|
268
282
|
flags=("--dry-run", "--author=", "--limit="),
|
|
269
283
|
category="git",
|
|
270
284
|
icon="๐",
|
|
271
|
-
summary="Update every branch and worktree,
|
|
285
|
+
summary="Update every branch and worktree, pruning what has been merged",
|
|
272
286
|
script="git/sync-all-branches.py",
|
|
273
287
|
sound=7,
|
|
274
288
|
dry_run="env",
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""๐ฃ Check out every local branch and hard reset it to origin/<branch>.
|
|
3
|
+
|
|
4
|
+
Fetch once, then for each local branch that has an origin/<branch> and differs
|
|
5
|
+
from it: check it out and ``git reset --hard`` to the origin state. A branch
|
|
6
|
+
checked out in another worktree is reset inside that worktree instead. Local
|
|
7
|
+
commits are dropped (they stay in the reflog); untracked files are left alone.
|
|
8
|
+
Returns to the starting branch at the end.
|
|
9
|
+
|
|
10
|
+
Uncommitted changes to tracked files are never discarded without --force:
|
|
11
|
+
the current worktree refuses to start, another dirty worktree is skipped.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
hard-reset-all-branches.py [--dry-run] [--force]
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--dry-run Show what would be reset and what it would drop, change nothing
|
|
18
|
+
--force Also discard uncommitted changes to tracked files
|
|
19
|
+
--help Show help
|
|
20
|
+
|
|
21
|
+
Env:
|
|
22
|
+
DRY_RUN=true Same as --dry-run (used by the shell -dr wrapper)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
31
|
+
|
|
32
|
+
from branchsync import in_progress
|
|
33
|
+
from gitutils import (
|
|
34
|
+
current_branch,
|
|
35
|
+
fetch_remotes,
|
|
36
|
+
for_each_ref,
|
|
37
|
+
has_tracked_changes,
|
|
38
|
+
local_branches,
|
|
39
|
+
skip_git_hooks,
|
|
40
|
+
worktree_branch_map,
|
|
41
|
+
)
|
|
42
|
+
from utils import (
|
|
43
|
+
add_bool_flag,
|
|
44
|
+
build_parser,
|
|
45
|
+
dry_run_complete,
|
|
46
|
+
env_bool,
|
|
47
|
+
heading,
|
|
48
|
+
info,
|
|
49
|
+
log,
|
|
50
|
+
require_binary,
|
|
51
|
+
run,
|
|
52
|
+
run_cli,
|
|
53
|
+
run_output,
|
|
54
|
+
success,
|
|
55
|
+
warning,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__version__ = "1.0.0"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def origin_names(cwd: Path) -> set[str]:
|
|
62
|
+
"""Branch names under ``refs/remotes/origin/``, minus the ``HEAD`` symref."""
|
|
63
|
+
return {
|
|
64
|
+
branch
|
|
65
|
+
for branch, symref in for_each_ref(
|
|
66
|
+
"refs/remotes/origin/", "refname:lstrip=3", "symref", cwd=cwd
|
|
67
|
+
)
|
|
68
|
+
if not symref
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def divergence(branch: str, cwd: Path) -> tuple[int, int]:
|
|
73
|
+
"""``(local-only commits, origin-only commits)`` between ``branch`` and origin.
|
|
74
|
+
|
|
75
|
+
Full refs throughout, so a tag named like the branch cannot be picked instead.
|
|
76
|
+
"""
|
|
77
|
+
counts = run_output(
|
|
78
|
+
[
|
|
79
|
+
"git",
|
|
80
|
+
"rev-list",
|
|
81
|
+
"--left-right",
|
|
82
|
+
"--count",
|
|
83
|
+
f"refs/heads/{branch}...refs/remotes/origin/{branch}",
|
|
84
|
+
],
|
|
85
|
+
cwd=cwd,
|
|
86
|
+
).split()
|
|
87
|
+
return int(counts[0]), int(counts[1])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def describe(ahead: int, behind: int) -> str:
|
|
91
|
+
parts = []
|
|
92
|
+
if ahead:
|
|
93
|
+
parts.append(f"drops {ahead} local commit(s)")
|
|
94
|
+
if behind:
|
|
95
|
+
parts.append(f"picks up {behind} from origin")
|
|
96
|
+
return ", ".join(parts)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv: list[str] | None = None) -> int:
|
|
100
|
+
parser = build_parser(
|
|
101
|
+
prog="hard-reset-all-branches",
|
|
102
|
+
description="๐ฃ Check out every local branch and hard reset it to origin/<branch>.",
|
|
103
|
+
)
|
|
104
|
+
add_bool_flag(
|
|
105
|
+
parser,
|
|
106
|
+
"--dry-run",
|
|
107
|
+
default=env_bool("DRY_RUN", True),
|
|
108
|
+
help="Show what would be reset and what it would drop, change nothing",
|
|
109
|
+
)
|
|
110
|
+
add_bool_flag(
|
|
111
|
+
parser,
|
|
112
|
+
"--force",
|
|
113
|
+
help="Also discard uncommitted changes to tracked files",
|
|
114
|
+
)
|
|
115
|
+
args = parser.parse_args(argv)
|
|
116
|
+
|
|
117
|
+
require_binary("git")
|
|
118
|
+
skip_git_hooks()
|
|
119
|
+
|
|
120
|
+
heading("๐ฃ hard-reset-all-branches", dry_run=args.dry_run)
|
|
121
|
+
|
|
122
|
+
if run_output(["git", "rev-parse", "--is-inside-work-tree"], check=False) != "true":
|
|
123
|
+
warning("โ Not inside a git working tree.")
|
|
124
|
+
return 1
|
|
125
|
+
root = Path(run_output(["git", "rev-parse", "--show-toplevel"]))
|
|
126
|
+
|
|
127
|
+
original = current_branch(root)
|
|
128
|
+
if not original:
|
|
129
|
+
warning("โ Detached HEAD โ check out a branch first.")
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
if in_progress(root):
|
|
133
|
+
warning("โ A rebase, merge or similar is in progress here โ finish or abort it first.")
|
|
134
|
+
return 1
|
|
135
|
+
|
|
136
|
+
if has_tracked_changes(root) and not args.force:
|
|
137
|
+
if not args.dry_run:
|
|
138
|
+
warning("โ Working tree is dirty. Commit, stash or pass --force to discard. ๐งบ")
|
|
139
|
+
return 1
|
|
140
|
+
warning("โ ๏ธ Working tree is dirty: a real run refuses without --force.")
|
|
141
|
+
|
|
142
|
+
# Fetched in a dry run too: the preview is only worth reading if it
|
|
143
|
+
# compares against what origin holds now. A fetch touches no local branch.
|
|
144
|
+
fetch_remotes()
|
|
145
|
+
|
|
146
|
+
remote = origin_names(root)
|
|
147
|
+
no_origin: list[str] = []
|
|
148
|
+
in_sync: list[str] = []
|
|
149
|
+
targets: list[tuple[str, int, int]] = []
|
|
150
|
+
for (branch,) in local_branches(cwd=root):
|
|
151
|
+
if branch not in remote:
|
|
152
|
+
no_origin.append(branch)
|
|
153
|
+
continue
|
|
154
|
+
ahead, behind = divergence(branch, root)
|
|
155
|
+
if ahead or behind:
|
|
156
|
+
targets.append((branch, ahead, behind))
|
|
157
|
+
else:
|
|
158
|
+
in_sync.append(branch)
|
|
159
|
+
|
|
160
|
+
if no_origin:
|
|
161
|
+
info(f"โญ๏ธ {len(no_origin)} branch(es) have no origin/<branch>, left alone:")
|
|
162
|
+
for branch in no_origin:
|
|
163
|
+
info(f" โข ๐ฟ {branch}")
|
|
164
|
+
|
|
165
|
+
if not targets:
|
|
166
|
+
success(f"โจ All {len(in_sync)} branch(es) with an origin already match it. ๐")
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
# Branches checked out in ANOTHER worktree cannot be checked out here, so
|
|
170
|
+
# they are reset where they are. The current branch is not among them: it
|
|
171
|
+
# is checked out (a no-op) and reset like the rest.
|
|
172
|
+
elsewhere = worktree_branch_map(root)
|
|
173
|
+
elsewhere.pop(original, None)
|
|
174
|
+
|
|
175
|
+
if args.dry_run:
|
|
176
|
+
warning(f"๐ต {len(targets)} branch(es) would be hard reset to origin:")
|
|
177
|
+
for branch, ahead, behind in targets:
|
|
178
|
+
where = f" (in {elsewhere[branch]})" if branch in elsewhere else ""
|
|
179
|
+
warning(f" โข ๐ฃ {branch}{where} โ {describe(ahead, behind)}")
|
|
180
|
+
dry_run_complete("reset")
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
reset: list[str] = []
|
|
184
|
+
skipped: list[tuple[str, str]] = []
|
|
185
|
+
failed: list[str] = []
|
|
186
|
+
checkout = ["git", "checkout", "--quiet", *(["--force"] if args.force else [])]
|
|
187
|
+
|
|
188
|
+
for branch, ahead, behind in targets:
|
|
189
|
+
if pinned := elsewhere.get(branch):
|
|
190
|
+
path = Path(pinned)
|
|
191
|
+
if in_progress(path):
|
|
192
|
+
warning(f" โญ๏ธ {branch} โ operation in progress in {pinned}, skipping")
|
|
193
|
+
skipped.append((branch, "operation in progress"))
|
|
194
|
+
continue
|
|
195
|
+
# Uncommitted work in another worktree is the user's; only --force
|
|
196
|
+
# may throw it away.
|
|
197
|
+
if has_tracked_changes(path) and not args.force:
|
|
198
|
+
warning(f" โญ๏ธ {branch} โ worktree is dirty ({pinned}), skipping")
|
|
199
|
+
skipped.append((branch, "dirty worktree"))
|
|
200
|
+
continue
|
|
201
|
+
log(f"๐ฃ Resetting {branch} in its worktree ({pinned})")
|
|
202
|
+
git, where = ["git", "-C", pinned], f" (in {pinned})"
|
|
203
|
+
else:
|
|
204
|
+
info(f"๐ Switching to ๐ฟ {branch}")
|
|
205
|
+
if run([*checkout, branch], check=False, capture=True, cwd=root).returncode != 0:
|
|
206
|
+
warning(" โ checkout failed, skipping")
|
|
207
|
+
failed.append(branch)
|
|
208
|
+
continue
|
|
209
|
+
log(f"๐ฃ Resetting {branch} to origin/{branch}")
|
|
210
|
+
git, where = ["git", "-C", str(root)], ""
|
|
211
|
+
|
|
212
|
+
before = run_output([*git, "rev-parse", "--short", "HEAD"])
|
|
213
|
+
completed = run(
|
|
214
|
+
[*git, "reset", "--hard", "--quiet", f"refs/remotes/origin/{branch}"],
|
|
215
|
+
check=False,
|
|
216
|
+
capture=True,
|
|
217
|
+
)
|
|
218
|
+
if completed.returncode != 0:
|
|
219
|
+
warning(f" โ reset failed: {(completed.stderr or '').strip()}")
|
|
220
|
+
failed.append(branch)
|
|
221
|
+
continue
|
|
222
|
+
success(f" โ
{branch} matches origin{where} (was {before}; {describe(ahead, behind)})")
|
|
223
|
+
reset.append(branch)
|
|
224
|
+
|
|
225
|
+
info(f"๐ Returning to ๐ฟ {original}")
|
|
226
|
+
run(["git", "checkout", "--quiet", original], cwd=root)
|
|
227
|
+
|
|
228
|
+
print()
|
|
229
|
+
success("โโโ summary โโโ")
|
|
230
|
+
success(f"โ
Reset: {len(reset)}")
|
|
231
|
+
if in_sync:
|
|
232
|
+
success(f"โจ In sync: {len(in_sync)}")
|
|
233
|
+
if reset:
|
|
234
|
+
info("๐ก Dropped commits stay reachable for a while: git reflog show <branch>")
|
|
235
|
+
if skipped:
|
|
236
|
+
warning(f"โญ๏ธ Skipped: {len(skipped)}")
|
|
237
|
+
for branch, reason in skipped:
|
|
238
|
+
warning(f" โข ๐ฟ {branch} ({reason})")
|
|
239
|
+
if failed:
|
|
240
|
+
warning(f"โ Failed: {len(failed)}")
|
|
241
|
+
for branch in failed:
|
|
242
|
+
warning(f" โข ๐ฅ {branch}")
|
|
243
|
+
return 1
|
|
244
|
+
|
|
245
|
+
success("๐ All clean!")
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
run_cli(main)
|