@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/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
@@ -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, preserving locks and local work",
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)