@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.
@@ -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="Delete merged branches, rebase the rest, check out new ones; branches only",
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", "prune"),
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, spelled the way sync-all-branches tells its own children;
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)