@codebend3r/che 1.2.0 → 1.2.2
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 +259 -230
- package/bin/branchsync.py +324 -0
- package/bin/commands.py +17 -4
- package/bin/git/hard-reset-all-branches.py +250 -0
- package/bin/git/sync-all-branches.py +79 -72
- package/bin/gitutils.py +4 -4
- 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
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Non-destructive branch/worktree synchronization for sync-all-branches.
|
|
2
|
+
|
|
3
|
+
Unlike the old delegated cleanup, sync preserves every checkout and local branch.
|
|
4
|
+
Only disposable worktrees created here may be removed. Git locks need no special
|
|
5
|
+
handling: they prevent removal/pruning, not a rebase inside the locked checkout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from gitutils import Worktree, local_branches, worktrees
|
|
16
|
+
from utils import info, run, run_output, success, warning
|
|
17
|
+
|
|
18
|
+
_OPERATION_FILES = (
|
|
19
|
+
"rebase-merge",
|
|
20
|
+
"rebase-apply",
|
|
21
|
+
"MERGE_HEAD",
|
|
22
|
+
"CHERRY_PICK_HEAD",
|
|
23
|
+
"REVERT_HEAD",
|
|
24
|
+
"BISECT_START",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def git_path(path: Path, name: str) -> Path:
|
|
29
|
+
result = Path(run_output(["git", "rev-parse", "--git-path", name], cwd=path))
|
|
30
|
+
return result if result.is_absolute() else path / result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def operation_branch(path: Path) -> str:
|
|
34
|
+
"""A rebase/bisect detaches HEAD but still reserves its original branch."""
|
|
35
|
+
for name in ("rebase-merge/head-name", "rebase-apply/head-name", "BISECT_START"):
|
|
36
|
+
file = git_path(path, name)
|
|
37
|
+
if file.is_file():
|
|
38
|
+
return file.read_text().splitlines()[0].removeprefix("refs/heads/")
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def in_progress(path: Path) -> bool:
|
|
43
|
+
return any(git_path(path, name).exists() for name in _OPERATION_FILES)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class BranchSync:
|
|
48
|
+
root: Path
|
|
49
|
+
dry_run: bool
|
|
50
|
+
updated: list[str] = field(default_factory=list)
|
|
51
|
+
current: list[str] = field(default_factory=list)
|
|
52
|
+
local_only: list[str] = field(default_factory=list)
|
|
53
|
+
restored: list[str] = field(default_factory=list)
|
|
54
|
+
blocked: list[str] = field(default_factory=list)
|
|
55
|
+
trees: list[Worktree] = field(init=False)
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
self.trees = worktrees(self.root)
|
|
59
|
+
|
|
60
|
+
def block(self, branch: str, reason: str) -> None:
|
|
61
|
+
message = f"{branch}: {reason}"
|
|
62
|
+
self.blocked.append(message)
|
|
63
|
+
warning(f" ❌ {message}")
|
|
64
|
+
|
|
65
|
+
def occupied(self) -> dict[str, Path]:
|
|
66
|
+
result: dict[str, Path] = {}
|
|
67
|
+
for tree in self.trees:
|
|
68
|
+
path = Path(tree.path)
|
|
69
|
+
branch = tree.branch
|
|
70
|
+
if not branch and path.is_dir():
|
|
71
|
+
branch = operation_branch(path)
|
|
72
|
+
if branch:
|
|
73
|
+
result[branch] = path
|
|
74
|
+
else:
|
|
75
|
+
info(f" ℹ️ Detached worktree unchanged (no upstream): {path}") # noqa: RUF001
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
def pathspecs(self, path: Path) -> list[str]:
|
|
79
|
+
"""Never stash another registered checkout nested inside this one.
|
|
80
|
+
|
|
81
|
+
Restrict status and stash by the same literal pathspecs, so untracked
|
|
82
|
+
siblings still count as WIP while a nested worktree stays untouched.
|
|
83
|
+
"""
|
|
84
|
+
specs = ["."]
|
|
85
|
+
for tree in self.trees:
|
|
86
|
+
nested = Path(tree.path)
|
|
87
|
+
if nested != path and nested.is_relative_to(path):
|
|
88
|
+
specs.append(f":(exclude,literal){nested.relative_to(path).as_posix()}")
|
|
89
|
+
return specs
|
|
90
|
+
|
|
91
|
+
def dirty(self, path: Path) -> bool:
|
|
92
|
+
return bool(
|
|
93
|
+
run_output(
|
|
94
|
+
[
|
|
95
|
+
"git",
|
|
96
|
+
"status",
|
|
97
|
+
"--porcelain",
|
|
98
|
+
"-z",
|
|
99
|
+
"--untracked-files=all",
|
|
100
|
+
"--",
|
|
101
|
+
*self.pathspecs(path),
|
|
102
|
+
],
|
|
103
|
+
cwd=path,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def has_ref(self, ref: str) -> bool:
|
|
108
|
+
return (
|
|
109
|
+
run(
|
|
110
|
+
["git", "show-ref", "--verify", "--quiet", ref],
|
|
111
|
+
cwd=self.root,
|
|
112
|
+
check=False,
|
|
113
|
+
capture=True,
|
|
114
|
+
).returncode
|
|
115
|
+
== 0
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def includes(self, branch: str, upstream: str) -> bool:
|
|
119
|
+
result = run(
|
|
120
|
+
["git", "merge-base", "--is-ancestor", upstream, f"refs/heads/{branch}"],
|
|
121
|
+
cwd=self.root,
|
|
122
|
+
check=False,
|
|
123
|
+
capture=True,
|
|
124
|
+
)
|
|
125
|
+
if result.returncode not in (0, 1):
|
|
126
|
+
raise subprocess.CalledProcessError(
|
|
127
|
+
result.returncode, result.args, stderr=result.stderr
|
|
128
|
+
)
|
|
129
|
+
return result.returncode == 0
|
|
130
|
+
|
|
131
|
+
def save_wip(self, branch: str, path: Path) -> str:
|
|
132
|
+
if not self.dirty(path):
|
|
133
|
+
return ""
|
|
134
|
+
# A superproject stash cannot preserve dirty submodule working copies.
|
|
135
|
+
submodules = run(
|
|
136
|
+
["git", "submodule", "foreach", "--quiet", "--recursive", "git status --porcelain"],
|
|
137
|
+
cwd=path,
|
|
138
|
+
check=False,
|
|
139
|
+
capture=True,
|
|
140
|
+
)
|
|
141
|
+
if submodules.returncode or submodules.stdout:
|
|
142
|
+
raise OSError(f"dirty/unreadable submodule in {path}; left untouched")
|
|
143
|
+
before = run_output(["git", "rev-parse", "--verify", "refs/stash"], cwd=path, check=False)
|
|
144
|
+
run(
|
|
145
|
+
[
|
|
146
|
+
"git",
|
|
147
|
+
"stash",
|
|
148
|
+
"push",
|
|
149
|
+
"--include-untracked",
|
|
150
|
+
"--message",
|
|
151
|
+
f"che sync-all-branches: {branch}",
|
|
152
|
+
"--",
|
|
153
|
+
*self.pathspecs(path),
|
|
154
|
+
],
|
|
155
|
+
cwd=path,
|
|
156
|
+
)
|
|
157
|
+
oid = run_output(["git", "rev-parse", "--verify", "refs/stash"], cwd=path)
|
|
158
|
+
if not oid or oid == before:
|
|
159
|
+
raise OSError(
|
|
160
|
+
f"could not verify a new recovery stash in {path}; inspect git stash list"
|
|
161
|
+
)
|
|
162
|
+
info(f" 🧺 Recovery stash for {branch} in {path}: {oid}")
|
|
163
|
+
if self.dirty(path):
|
|
164
|
+
raise OSError(f"worktree remains dirty; left untouched, recovery stash {oid} in {path}")
|
|
165
|
+
return oid
|
|
166
|
+
|
|
167
|
+
def restore_wip(self, branch: str, path: Path, oid: str) -> bool:
|
|
168
|
+
if not oid:
|
|
169
|
+
return True
|
|
170
|
+
# Worktrees share refs/stash. Never pop the latest entry or drop a
|
|
171
|
+
# recovery copy until both working-copy and index restoration succeed.
|
|
172
|
+
if run(["git", "stash", "apply", "--index", oid], cwd=path, check=False).returncode:
|
|
173
|
+
self.block(
|
|
174
|
+
branch, f"WIP restoration conflicted in {path}; recovery stash retained: {oid}"
|
|
175
|
+
)
|
|
176
|
+
return False
|
|
177
|
+
entries = run_output(["git", "stash", "list", "--format=%gd%x01%H"], cwd=path)
|
|
178
|
+
matching = next(
|
|
179
|
+
(
|
|
180
|
+
ref
|
|
181
|
+
for line in entries.splitlines()
|
|
182
|
+
for ref, sha in [line.split("\x01")]
|
|
183
|
+
if sha == oid
|
|
184
|
+
),
|
|
185
|
+
"",
|
|
186
|
+
)
|
|
187
|
+
if (
|
|
188
|
+
not matching
|
|
189
|
+
or run(["git", "stash", "drop", "--quiet", matching], cwd=path, check=False).returncode
|
|
190
|
+
):
|
|
191
|
+
self.block(branch, f"WIP restored in {path}, but recovery stash not dropped: {oid}")
|
|
192
|
+
return False
|
|
193
|
+
self.restored.append(branch)
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
def sync_branch(self, branch: str, upstream: str, occupied: dict[str, Path]) -> None:
|
|
197
|
+
path = occupied.get(branch)
|
|
198
|
+
if path is not None:
|
|
199
|
+
if not path.is_dir():
|
|
200
|
+
self.block(branch, f"worktree unavailable: {path}")
|
|
201
|
+
return
|
|
202
|
+
if in_progress(path):
|
|
203
|
+
self.block(branch, f"operation in progress in {path}; left untouched")
|
|
204
|
+
return
|
|
205
|
+
if self.includes(branch, upstream):
|
|
206
|
+
info(f" = Up to date: {branch} (local commits kept){f' in {path}' if path else ''}")
|
|
207
|
+
self.current.append(branch)
|
|
208
|
+
return
|
|
209
|
+
if self.dry_run:
|
|
210
|
+
info(
|
|
211
|
+
f" 🌵 Would update {branch} from {upstream}{f' in {path}' if path else ''}; stash/restore WIP if needed"
|
|
212
|
+
)
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
temporary: Path | None = None
|
|
216
|
+
oid = ""
|
|
217
|
+
try:
|
|
218
|
+
if path is None:
|
|
219
|
+
temporary = Path(tempfile.mkdtemp(prefix="che-sync-"))
|
|
220
|
+
path = temporary / "checkout"
|
|
221
|
+
run(["git", "worktree", "add", "--quiet", str(path), branch], cwd=self.root)
|
|
222
|
+
oid = self.save_wip(branch, path)
|
|
223
|
+
info(f"⬇️ Rebasing {branch} in {path} (lock preserved)")
|
|
224
|
+
# DELIBERATE DIVERGENCE: one fetched snapshot instead of pull's
|
|
225
|
+
# per-branch fetch. Disable autostash/updateRefs so Git cannot
|
|
226
|
+
# compete with our exact WIP recovery or move unrelated branches.
|
|
227
|
+
rebased = (
|
|
228
|
+
run(
|
|
229
|
+
[
|
|
230
|
+
"git",
|
|
231
|
+
"-c",
|
|
232
|
+
"rebase.autoStash=false",
|
|
233
|
+
"-c",
|
|
234
|
+
"rebase.updateRefs=false",
|
|
235
|
+
"rebase",
|
|
236
|
+
"--no-autostash",
|
|
237
|
+
upstream,
|
|
238
|
+
],
|
|
239
|
+
cwd=path,
|
|
240
|
+
check=False,
|
|
241
|
+
).returncode
|
|
242
|
+
== 0
|
|
243
|
+
)
|
|
244
|
+
if not rebased:
|
|
245
|
+
warning(f" ❌ Rebase failed for {branch} in {path}")
|
|
246
|
+
if in_progress(path):
|
|
247
|
+
# Abort only the rebase we started. Failure to abort is
|
|
248
|
+
# fatal: applying WIP or continuing is no longer safe.
|
|
249
|
+
abort = run(["git", "rebase", "--abort"], cwd=path, check=False)
|
|
250
|
+
if abort.returncode:
|
|
251
|
+
warning(f"STOP: cannot abort in {path}; recovery stash: {oid or 'none'}")
|
|
252
|
+
raise SystemExit(1)
|
|
253
|
+
self.block(branch, f"rebase failed in {path}; original commits retained")
|
|
254
|
+
restored = self.restore_wip(branch, path, oid)
|
|
255
|
+
if rebased and restored:
|
|
256
|
+
if self.includes(branch, upstream):
|
|
257
|
+
self.updated.append(branch)
|
|
258
|
+
success(f" ✅ Updated: {branch}")
|
|
259
|
+
else:
|
|
260
|
+
self.block(branch, "verification failed: fetched upstream not included")
|
|
261
|
+
except (subprocess.CalledProcessError, OSError) as exc:
|
|
262
|
+
self.block(
|
|
263
|
+
branch, f"{exc}; inspect {path}; recovery stash: {oid or 'see git stash list'}"
|
|
264
|
+
)
|
|
265
|
+
except KeyboardInterrupt:
|
|
266
|
+
warning(
|
|
267
|
+
f"Interrupted in {path}; inspect status before resuming. Recovery stash: {oid or 'see git stash list'}"
|
|
268
|
+
)
|
|
269
|
+
raise
|
|
270
|
+
finally:
|
|
271
|
+
# Never use TemporaryDirectory here: its automatic recursive
|
|
272
|
+
# deletion would erase files if a hook or recovery left a dirty tree.
|
|
273
|
+
if temporary is not None:
|
|
274
|
+
if not (temporary / "checkout").exists():
|
|
275
|
+
temporary.rmdir()
|
|
276
|
+
elif (
|
|
277
|
+
not in_progress(temporary / "checkout")
|
|
278
|
+
and run(
|
|
279
|
+
["git", "worktree", "remove", str(temporary / "checkout")],
|
|
280
|
+
cwd=self.root,
|
|
281
|
+
check=False,
|
|
282
|
+
).returncode
|
|
283
|
+
== 0
|
|
284
|
+
):
|
|
285
|
+
temporary.rmdir()
|
|
286
|
+
else:
|
|
287
|
+
self.block(
|
|
288
|
+
branch,
|
|
289
|
+
f"temporary worktree retained for inspection: {temporary / 'checkout'}",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def update(self) -> None:
|
|
293
|
+
occupied = self.occupied()
|
|
294
|
+
for branch, upstream in local_branches("upstream", cwd=self.root):
|
|
295
|
+
if not upstream and self.has_ref(f"refs/remotes/origin/{branch}"):
|
|
296
|
+
upstream = f"refs/remotes/origin/{branch}"
|
|
297
|
+
if not upstream:
|
|
298
|
+
info(f" ℹ️ Local only: {branch} (no remote counterpart)") # noqa: RUF001
|
|
299
|
+
self.local_only.append(branch)
|
|
300
|
+
continue
|
|
301
|
+
if not self.has_ref(upstream):
|
|
302
|
+
self.block(branch, f"upstream gone: {upstream}; branch and worktree retained")
|
|
303
|
+
continue
|
|
304
|
+
try:
|
|
305
|
+
self.sync_branch(branch, upstream, occupied)
|
|
306
|
+
except (subprocess.CalledProcessError, OSError) as exc:
|
|
307
|
+
self.block(branch, f"preflight failed: {exc}")
|
|
308
|
+
|
|
309
|
+
def summary(self, tracked: list[str]) -> None:
|
|
310
|
+
success("═══ summary ═══")
|
|
311
|
+
for label, names in (
|
|
312
|
+
("Updated", self.updated),
|
|
313
|
+
("Up to date", self.current),
|
|
314
|
+
("Tracked new", tracked),
|
|
315
|
+
("Local only", self.local_only),
|
|
316
|
+
("WIP restored", self.restored),
|
|
317
|
+
):
|
|
318
|
+
info(f"{label}: {len(names)} ({', '.join(names)})")
|
|
319
|
+
info(f"Blocked: {len(self.blocked)}")
|
|
320
|
+
for reason in self.blocked:
|
|
321
|
+
warning(f" • {reason}")
|
|
322
|
+
info(
|
|
323
|
+
"Caller checkout, existing worktree paths, locks and local commits preserved. Nothing pushed."
|
|
324
|
+
)
|
package/bin/commands.py
CHANGED
|
@@ -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,13 +282,13 @@ COMMANDS: tuple[Command, ...] = (
|
|
|
268
282
|
flags=("--dry-run", "--author=", "--limit="),
|
|
269
283
|
category="git",
|
|
270
284
|
icon="🔄",
|
|
271
|
-
summary="
|
|
285
|
+
summary="Update every branch and worktree, preserving locks and local work",
|
|
272
286
|
script="git/sync-all-branches.py",
|
|
273
287
|
sound=7,
|
|
274
288
|
dry_run="env",
|
|
275
289
|
destructive=True,
|
|
276
290
|
needs=("git",),
|
|
277
|
-
tags=("branch", "sync", "pull", "
|
|
291
|
+
tags=("branch", "sync", "pull", "worktree"),
|
|
278
292
|
),
|
|
279
293
|
Command(
|
|
280
294
|
name="update-from-origin",
|
|
@@ -283,8 +297,7 @@ COMMANDS: tuple[Command, ...] = (
|
|
|
283
297
|
icon="⬇️",
|
|
284
298
|
summary="Alias of sync-all-branches",
|
|
285
299
|
script="git/sync-all-branches.py",
|
|
286
|
-
# A real run
|
|
287
|
-
# a `--dry-run` typed after it still wins.
|
|
300
|
+
# A real run by default; a `--dry-run` typed after it still wins.
|
|
288
301
|
args=("--dry-run=false",),
|
|
289
302
|
sound=7,
|
|
290
303
|
destructive=True,
|
|
@@ -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)
|