bingo-light 2.0.0
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/LICENSE +21 -0
- package/README.md +522 -0
- package/README.zh-CN.md +534 -0
- package/bin/cli.js +46 -0
- package/bin/mcp.js +45 -0
- package/bingo-light +1094 -0
- package/bingo_core/__init__.py +77 -0
- package/bingo_core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/_entry.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/config.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/exceptions.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/git.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/models.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/repo.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/setup.cpython-313.pyc +0 -0
- package/bingo_core/__pycache__/state.cpython-313.pyc +0 -0
- package/bingo_core/config.py +110 -0
- package/bingo_core/exceptions.py +48 -0
- package/bingo_core/git.py +194 -0
- package/bingo_core/models.py +37 -0
- package/bingo_core/repo.py +2376 -0
- package/bingo_core/setup.py +549 -0
- package/bingo_core/state.py +306 -0
- package/completions/bingo-light.bash +118 -0
- package/completions/bingo-light.fish +197 -0
- package/completions/bingo-light.zsh +169 -0
- package/mcp-server.py +788 -0
- package/package.json +34 -0
package/bingo-light
ADDED
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""bingo-light — AI-native fork maintenance tool.
|
|
3
|
+
|
|
4
|
+
CLI entry point. Delegates all business logic to bingo_core.Repo.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
# ─── Ensure the directory containing this script is on sys.path ──────────────
|
|
17
|
+
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
if _SCRIPT_DIR not in sys.path:
|
|
19
|
+
sys.path.insert(0, _SCRIPT_DIR)
|
|
20
|
+
|
|
21
|
+
from bingo_core import VERSION, BingoError, Repo # noqa: E402
|
|
22
|
+
from bingo_core.setup import run_setup # noqa: E402
|
|
23
|
+
|
|
24
|
+
# ─── Colors ──────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
BOLD = "\033[1m"
|
|
27
|
+
RED = "\033[31m"
|
|
28
|
+
GREEN = "\033[32m"
|
|
29
|
+
YELLOW = "\033[33m"
|
|
30
|
+
CYAN = "\033[36m"
|
|
31
|
+
DIM = "\033[2m"
|
|
32
|
+
RESET = "\033[0m"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _colors_enabled() -> bool:
|
|
36
|
+
"""Return True if stdout is a TTY and NO_COLOR is not set."""
|
|
37
|
+
return sys.stdout.isatty() and not os.environ.get("NO_COLOR", "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _c(code: str, text: str) -> str:
|
|
41
|
+
"""Wrap text in an ANSI code if colors are enabled."""
|
|
42
|
+
if _colors_enabled():
|
|
43
|
+
return f"{code}{text}{RESET}"
|
|
44
|
+
return text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ─── Output helpers ──────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _json_print(data: dict) -> None:
|
|
51
|
+
"""Print a dict as compact JSON to stdout."""
|
|
52
|
+
print(json.dumps(data, default=str))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _error_exit(msg: str, json_mode: bool) -> None:
|
|
56
|
+
"""Print error and exit 1."""
|
|
57
|
+
if json_mode:
|
|
58
|
+
_json_print({"ok": False, "error": str(msg)})
|
|
59
|
+
else:
|
|
60
|
+
print(f"{_c(RED, 'x')} {msg}", file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ─── Human-readable formatters ──────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _format_status(result: dict) -> str:
|
|
68
|
+
"""Format status result for human reading."""
|
|
69
|
+
lines = []
|
|
70
|
+
lines.append("")
|
|
71
|
+
lines.append(f" {_c(BOLD, 'Upstream:')} {result.get('upstream_url', '?')}")
|
|
72
|
+
lines.append(f" {_c(BOLD, 'Branch:')} {result.get('upstream_branch', '?')}")
|
|
73
|
+
lines.append(f" {_c(BOLD, 'Current:')} {result.get('current_branch', '?')}")
|
|
74
|
+
lines.append("")
|
|
75
|
+
|
|
76
|
+
behind = result.get("behind", 0)
|
|
77
|
+
if behind == 0:
|
|
78
|
+
lines.append(f" {_c(GREEN, 'Up to date with upstream')}")
|
|
79
|
+
else:
|
|
80
|
+
lines.append(f" {_c(YELLOW, f'{behind} commit(s) behind upstream')}")
|
|
81
|
+
lines.append("")
|
|
82
|
+
|
|
83
|
+
patches = result.get("patches", [])
|
|
84
|
+
lines.append(f" {_c(BOLD, 'Patches:')} {len(patches)}")
|
|
85
|
+
lines.append("")
|
|
86
|
+
for i, p in enumerate(patches, 1):
|
|
87
|
+
name = p.get("name", "?")
|
|
88
|
+
subject = p.get("subject", "")
|
|
89
|
+
h = p.get("hash", "")[:7]
|
|
90
|
+
files = p.get("files", 0)
|
|
91
|
+
desc = re.sub(r"^\[bl\] [^:]+:\s*", "", subject) if subject else ""
|
|
92
|
+
lines.append(
|
|
93
|
+
f" {i} {_c(CYAN, name)} {desc} "
|
|
94
|
+
f"{_c(DIM, h)} {files} file(s)"
|
|
95
|
+
)
|
|
96
|
+
lines.append("")
|
|
97
|
+
return "\n".join(lines)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _format_patch_list(result: dict) -> str:
|
|
101
|
+
"""Format patch list for human reading."""
|
|
102
|
+
patches = result.get("patches", [])
|
|
103
|
+
lines = []
|
|
104
|
+
lines.append("")
|
|
105
|
+
lines.append(f"{_c(BOLD, 'Patch Stack')} (bottom = applied first)")
|
|
106
|
+
lines.append("")
|
|
107
|
+
for i, p in enumerate(patches, 1):
|
|
108
|
+
name = p.get("name", "?")
|
|
109
|
+
h = p.get("hash", "")[:7]
|
|
110
|
+
lines.append(f" {i} {_c(CYAN, name)} ({_c(DIM, h)})")
|
|
111
|
+
# Show file details if available (verbose mode)
|
|
112
|
+
file_details = p.get("file_details", [])
|
|
113
|
+
if file_details:
|
|
114
|
+
for fd in file_details:
|
|
115
|
+
lines.append(f" {fd}")
|
|
116
|
+
else:
|
|
117
|
+
stat = p.get("stat", "")
|
|
118
|
+
if stat:
|
|
119
|
+
lines.append(f" {stat}")
|
|
120
|
+
else:
|
|
121
|
+
ins = p.get("insertions", 0)
|
|
122
|
+
dele = p.get("deletions", 0)
|
|
123
|
+
files = p.get("files", 0)
|
|
124
|
+
fc = "file" if files == 1 else "files"
|
|
125
|
+
lines.append(
|
|
126
|
+
f" {files} {fc} changed, {ins} insertions(+), {dele} deletions(-)"
|
|
127
|
+
)
|
|
128
|
+
lines.append("")
|
|
129
|
+
lines.append(f" Total: {len(patches)} patch(es)")
|
|
130
|
+
lines.append("")
|
|
131
|
+
return "\n".join(lines)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _format_sync(result: dict) -> str:
|
|
135
|
+
"""Format sync result for human reading."""
|
|
136
|
+
if result.get("dry_run"):
|
|
137
|
+
behind = result.get("behind", 0)
|
|
138
|
+
patches = result.get("patches", 0)
|
|
139
|
+
return (
|
|
140
|
+
f"{_c(YELLOW, '!')} Dry run: {behind} new upstream commit(s), "
|
|
141
|
+
f"{patches} patch(es) to rebase."
|
|
142
|
+
)
|
|
143
|
+
if result.get("up_to_date"):
|
|
144
|
+
return f"{_c(GREEN, 'OK')} Already up to date."
|
|
145
|
+
if result.get("conflict"):
|
|
146
|
+
files = result.get("conflicted_files", [])
|
|
147
|
+
lines = [f"{_c(RED, 'x')} Sync conflict! Rebase paused."]
|
|
148
|
+
for f in files:
|
|
149
|
+
lines.append(f" {_c(YELLOW, '~')} {f}")
|
|
150
|
+
lines.append("\n Resolve conflicts, then: git add <file> && git rebase --continue")
|
|
151
|
+
lines.append(" Or abort: git rebase --abort")
|
|
152
|
+
return "\n".join(lines)
|
|
153
|
+
patches = result.get("patches_rebased", result.get("patches", 0))
|
|
154
|
+
return f"{_c(GREEN, 'OK')} Sync complete! {patches} patch(es) rebased cleanly."
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _format_doctor(result: dict) -> str:
|
|
158
|
+
"""Format doctor result for human reading."""
|
|
159
|
+
lines = []
|
|
160
|
+
checks = result.get("checks", [])
|
|
161
|
+
for check in checks:
|
|
162
|
+
name = check.get("name", "?")
|
|
163
|
+
status = check.get("status", "")
|
|
164
|
+
ok = status == "pass" or check.get("ok", False)
|
|
165
|
+
is_warn = status == "warn"
|
|
166
|
+
if ok:
|
|
167
|
+
symbol = _c(GREEN, "OK")
|
|
168
|
+
elif is_warn:
|
|
169
|
+
symbol = _c(YELLOW, "WARN")
|
|
170
|
+
else:
|
|
171
|
+
symbol = _c(RED, "FAIL")
|
|
172
|
+
detail = check.get("detail", check.get("message", ""))
|
|
173
|
+
lines.append(f" {symbol} {name}")
|
|
174
|
+
if detail and (not ok or is_warn):
|
|
175
|
+
lines.append(f" {_c(DIM, detail)}")
|
|
176
|
+
return "\n".join(lines)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _format_diff(result: dict) -> str:
|
|
180
|
+
"""Format diff result."""
|
|
181
|
+
diff_text = result.get("diff", "")
|
|
182
|
+
if not diff_text:
|
|
183
|
+
return "No changes vs upstream."
|
|
184
|
+
return diff_text
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _format_log(result: dict) -> str:
|
|
188
|
+
"""Format log result — compact one-line-per-sync."""
|
|
189
|
+
syncs = result.get("syncs", result.get("history", result.get("entries", [])))
|
|
190
|
+
if not syncs:
|
|
191
|
+
return "No sync history."
|
|
192
|
+
lines = []
|
|
193
|
+
for e in syncs:
|
|
194
|
+
ts = e.get("timestamp", "")
|
|
195
|
+
n = e.get("upstream_commits_integrated", 0)
|
|
196
|
+
patches = e.get("patches", [])
|
|
197
|
+
before = e.get("upstream_before", "")[:7]
|
|
198
|
+
after = e.get("upstream_after", "")[:7]
|
|
199
|
+
summary = f"{n} commit(s) integrated"
|
|
200
|
+
if before and after:
|
|
201
|
+
summary += f" {before} \u2192 {after}"
|
|
202
|
+
if patches:
|
|
203
|
+
summary += f" ({len(patches)} patch(es) rebased)"
|
|
204
|
+
lines.append(f" {_c(DIM, ts)} {summary}")
|
|
205
|
+
return "\n".join(lines)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _format_history(result: dict) -> str:
|
|
209
|
+
"""Format history result — verbose with per-patch hash mappings."""
|
|
210
|
+
syncs = result.get("syncs", result.get("history", result.get("entries", [])))
|
|
211
|
+
if not syncs:
|
|
212
|
+
return "No sync history."
|
|
213
|
+
lines = []
|
|
214
|
+
for e in syncs:
|
|
215
|
+
ts = e.get("timestamp", "")
|
|
216
|
+
n = e.get("upstream_commits_integrated", 0)
|
|
217
|
+
before = e.get("upstream_before", "")[:7]
|
|
218
|
+
after = e.get("upstream_after", "")[:7]
|
|
219
|
+
patches = e.get("patches", [])
|
|
220
|
+
lines.append(f" {_c(BOLD, 'Sync')} @ {ts}")
|
|
221
|
+
lines.append(f" Upstream: {before} \u2192 {after} ({n} commit(s))")
|
|
222
|
+
if patches:
|
|
223
|
+
lines.append(" Patches rebased:")
|
|
224
|
+
for p in patches:
|
|
225
|
+
name = p.get("name", "?")
|
|
226
|
+
h = p.get("hash", "?")[:7]
|
|
227
|
+
lines.append(f" {name} {_c(DIM, h)}")
|
|
228
|
+
lines.append("")
|
|
229
|
+
return "\n".join(lines).rstrip()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _format_conflict_analyze(result: dict) -> str:
|
|
233
|
+
"""Format conflict analysis."""
|
|
234
|
+
conflicts = result.get("conflicts", [])
|
|
235
|
+
if not conflicts:
|
|
236
|
+
return f"{_c(GREEN, 'OK')} No conflicts detected."
|
|
237
|
+
lines = [f"{_c(YELLOW, '!')} {len(conflicts)} conflicting file(s):"]
|
|
238
|
+
for c in conflicts:
|
|
239
|
+
f = c.get("file", "?")
|
|
240
|
+
hint = c.get("merge_hint", "")
|
|
241
|
+
lines.append(f" {_c(RED, f)}")
|
|
242
|
+
if hint:
|
|
243
|
+
lines.append(f" hint: {hint}")
|
|
244
|
+
return "\n".join(lines)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _format_conflict_resolve(result: dict) -> str:
|
|
248
|
+
"""Format conflict-resolve result for human reading."""
|
|
249
|
+
if result.get("ok") is False:
|
|
250
|
+
return f"{_c(RED, 'x')} {result.get('error', 'Resolution failed.')}"
|
|
251
|
+
|
|
252
|
+
resolved = result.get("resolved", "?")
|
|
253
|
+
remaining = result.get("remaining", [])
|
|
254
|
+
|
|
255
|
+
if remaining:
|
|
256
|
+
n = len(remaining)
|
|
257
|
+
files = ", ".join(remaining)
|
|
258
|
+
return (
|
|
259
|
+
f"{_c(GREEN, 'OK')} Resolved {resolved}. "
|
|
260
|
+
f"{n} file(s) still in conflict: {files}"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if result.get("sync_complete"):
|
|
264
|
+
return (
|
|
265
|
+
f"{_c(GREEN, 'OK')} Resolved {resolved} "
|
|
266
|
+
f"\u2014 sync complete!"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if result.get("conflict"):
|
|
270
|
+
patch = result.get("current_patch", "")
|
|
271
|
+
conflicts = result.get("conflicts", [])
|
|
272
|
+
lines = [
|
|
273
|
+
f"{_c(GREEN, 'OK')} Resolved {resolved} "
|
|
274
|
+
f"\u2014 next patch has conflicts:"
|
|
275
|
+
]
|
|
276
|
+
if patch:
|
|
277
|
+
lines.append(f" Patch: {patch}")
|
|
278
|
+
for c in conflicts:
|
|
279
|
+
f = c.get("file", "?")
|
|
280
|
+
hint = c.get("merge_hint", "")
|
|
281
|
+
lines.append(f" {_c(YELLOW, '~')} {f}")
|
|
282
|
+
if hint:
|
|
283
|
+
lines.append(f" hint: {hint}")
|
|
284
|
+
return "\n".join(lines)
|
|
285
|
+
|
|
286
|
+
if result.get("rebase_continued"):
|
|
287
|
+
return (
|
|
288
|
+
f"{_c(GREEN, 'OK')} Resolved {resolved} "
|
|
289
|
+
f"\u2014 rebase continued."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return f"{_c(GREEN, 'OK')} Resolved {resolved}."
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _format_session(result: dict) -> str:
|
|
296
|
+
"""Format session output."""
|
|
297
|
+
content = result.get("content", result.get("session", ""))
|
|
298
|
+
if content:
|
|
299
|
+
return content
|
|
300
|
+
return result.get("message", "Session updated.")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _format_config(result: dict) -> str:
|
|
304
|
+
"""Format config output."""
|
|
305
|
+
# config list
|
|
306
|
+
items = result.get("items", result.get("config", None))
|
|
307
|
+
if items is not None:
|
|
308
|
+
if isinstance(items, dict):
|
|
309
|
+
lines = [f" {k} = {v}" for k, v in items.items()]
|
|
310
|
+
return "\n".join(lines) if lines else "No configuration set."
|
|
311
|
+
if isinstance(items, list):
|
|
312
|
+
lines = [f" {item}" for item in items]
|
|
313
|
+
return "\n".join(lines) if lines else "No configuration set."
|
|
314
|
+
# config get
|
|
315
|
+
value = result.get("value", None)
|
|
316
|
+
if value is not None:
|
|
317
|
+
return str(value)
|
|
318
|
+
return result.get("message", "OK")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _format_test(result: dict) -> str:
|
|
322
|
+
"""Format test result."""
|
|
323
|
+
passed = result.get("test") == "pass" or result.get("passed", False)
|
|
324
|
+
output = result.get("output", "")
|
|
325
|
+
status = _c(GREEN, "PASS") if passed else _c(RED, "FAIL")
|
|
326
|
+
lines = [f"Test: {status}"]
|
|
327
|
+
if output:
|
|
328
|
+
lines.append(output)
|
|
329
|
+
return "\n".join(lines)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _format_auto_sync(result: dict) -> str:
|
|
333
|
+
"""Format auto-sync result."""
|
|
334
|
+
schedule = result.get("schedule", "")
|
|
335
|
+
return f"{_c(GREEN, 'OK')} GitHub Actions workflow generated ({schedule})."
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _format_workspace(result: dict) -> str:
|
|
339
|
+
"""Format workspace result."""
|
|
340
|
+
# workspace list / status
|
|
341
|
+
repos = result.get("repos", None)
|
|
342
|
+
if repos is not None:
|
|
343
|
+
if not repos:
|
|
344
|
+
return "No repos in workspace."
|
|
345
|
+
lines = [f"{_c(BOLD, 'Workspace repos:')}"]
|
|
346
|
+
for r in repos:
|
|
347
|
+
alias = r.get("alias", "?")
|
|
348
|
+
path = r.get("path", "?")
|
|
349
|
+
status = r.get("status", "")
|
|
350
|
+
behind = r.get("behind", None)
|
|
351
|
+
patches = r.get("patches", None)
|
|
352
|
+
line = f" {_c(CYAN, alias)} {_c(DIM, path)}"
|
|
353
|
+
if behind is not None:
|
|
354
|
+
if behind > 0:
|
|
355
|
+
line += f" {_c(YELLOW, f'{behind} behind')}"
|
|
356
|
+
else:
|
|
357
|
+
line += f" {_c(GREEN, 'up to date')}"
|
|
358
|
+
line += f" {patches} patch(es)"
|
|
359
|
+
elif status == "missing":
|
|
360
|
+
line += f" {_c(RED, 'missing')}"
|
|
361
|
+
elif status == "error":
|
|
362
|
+
line += f" {_c(RED, r.get('error', 'error'))}"
|
|
363
|
+
lines.append(line)
|
|
364
|
+
return "\n".join(lines)
|
|
365
|
+
# workspace sync
|
|
366
|
+
synced = result.get("synced", None)
|
|
367
|
+
if synced is not None:
|
|
368
|
+
lines = []
|
|
369
|
+
for s in synced:
|
|
370
|
+
alias = s.get("alias", "?")
|
|
371
|
+
st = s.get("status", "?")
|
|
372
|
+
color = GREEN if st == "ok" else RED
|
|
373
|
+
lines.append(f" {_c(color, st):>12} {alias}")
|
|
374
|
+
return "\n".join(lines)
|
|
375
|
+
return result.get("message", "OK")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _format_patch_show(result: dict) -> str:
|
|
379
|
+
"""Format patch show."""
|
|
380
|
+
patch = result.get("patch", {})
|
|
381
|
+
diff_text = patch.get("diff", "") if isinstance(patch, dict) else ""
|
|
382
|
+
if not diff_text:
|
|
383
|
+
# fallback: top-level diff key
|
|
384
|
+
diff_text = result.get("diff", "")
|
|
385
|
+
return diff_text if diff_text else "Empty patch."
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _format_init(result: dict) -> str:
|
|
389
|
+
"""Format init result."""
|
|
390
|
+
upstream = result.get("upstream", "")
|
|
391
|
+
branch = result.get("branch", "")
|
|
392
|
+
reinit = result.get("reinit", False)
|
|
393
|
+
label = "Re-initialized (config updated)." if reinit else "Fork tracking initialized."
|
|
394
|
+
return (
|
|
395
|
+
f"{_c(GREEN, 'OK')} {label}\n"
|
|
396
|
+
f" Upstream: {upstream}\n"
|
|
397
|
+
f" Branch: {branch}\n"
|
|
398
|
+
f" Tracking: {result.get('tracking', '')}\n"
|
|
399
|
+
f" Patches: {result.get('patches', '')}"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _format_patch_new(result: dict) -> str:
|
|
404
|
+
"""Format patch new result."""
|
|
405
|
+
name = result.get("patch", "")
|
|
406
|
+
h = result.get("hash", "")
|
|
407
|
+
desc = result.get("description", "")
|
|
408
|
+
return (
|
|
409
|
+
f"{_c(GREEN, 'OK')} Patch created: {_c(CYAN, name)} ({_c(DIM, h)})\n"
|
|
410
|
+
f" {desc}"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _format_patch_drop(result: dict) -> str:
|
|
415
|
+
"""Format patch drop result."""
|
|
416
|
+
name = result.get("dropped", "")
|
|
417
|
+
h = result.get("hash", "")
|
|
418
|
+
return f"{_c(GREEN, 'OK')} Dropped patch: {_c(CYAN, name)} ({_c(DIM, h)})"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _format_patch_export(result: dict) -> str:
|
|
422
|
+
"""Format patch export result."""
|
|
423
|
+
count = result.get("count", 0)
|
|
424
|
+
directory = result.get("directory", "")
|
|
425
|
+
return f"{_c(GREEN, 'OK')} Exported {count} patch(es) to {directory}"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _format_patch_import(result: dict) -> str:
|
|
429
|
+
"""Format patch import result."""
|
|
430
|
+
count = result.get("patch_count", 0)
|
|
431
|
+
return f"{_c(GREEN, 'OK')} Import complete. {count} patch(es) in stack."
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _format_smart_sync(result: dict) -> str:
|
|
435
|
+
"""Format smart-sync result for human reading."""
|
|
436
|
+
if result.get("ok") is False:
|
|
437
|
+
action = result.get("action", "")
|
|
438
|
+
if action == "needs_human":
|
|
439
|
+
conflicts = result.get("remaining_conflicts", [])
|
|
440
|
+
auto = result.get("conflicts_auto_resolved", 0)
|
|
441
|
+
patch = result.get("current_patch", "")
|
|
442
|
+
lines = [f"{_c(RED, 'x')} Smart sync: unresolved conflict(s)."]
|
|
443
|
+
if patch:
|
|
444
|
+
lines.append(f" Patch: {patch}")
|
|
445
|
+
if auto:
|
|
446
|
+
lines.append(f" Auto-resolved: {auto} step(s) via rerere")
|
|
447
|
+
for c_ in conflicts:
|
|
448
|
+
f = c_.get("file", c_) if isinstance(c_, dict) else c_
|
|
449
|
+
lines.append(f" {_c(YELLOW, '~')} {f}")
|
|
450
|
+
lines.append(
|
|
451
|
+
"\n Resolve conflicts, then: git add <file> && "
|
|
452
|
+
"git rebase --continue"
|
|
453
|
+
)
|
|
454
|
+
lines.append(" Or abort: git rebase --abort")
|
|
455
|
+
return "\n".join(lines)
|
|
456
|
+
err = result.get("error", "Operation failed.")
|
|
457
|
+
return f"{_c(RED, 'x')} {err}"
|
|
458
|
+
action = result.get("action", "")
|
|
459
|
+
if action == "none":
|
|
460
|
+
return f"{_c(GREEN, 'OK')} Already up to date."
|
|
461
|
+
patches = result.get("patches_rebased", 0)
|
|
462
|
+
auto = result.get("conflicts_resolved", 0)
|
|
463
|
+
msg = f"{_c(GREEN, 'OK')} Sync complete! {patches} patch(es) rebased."
|
|
464
|
+
if auto:
|
|
465
|
+
msg += f" ({auto} conflict(s) auto-resolved via rerere)"
|
|
466
|
+
return msg
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _format_patch_meta(result: dict) -> str:
|
|
470
|
+
"""Format patch meta result for human reading."""
|
|
471
|
+
if result.get("ok") is False:
|
|
472
|
+
return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
|
|
473
|
+
# Set operation — echo what was set
|
|
474
|
+
if "set" in result:
|
|
475
|
+
k = result["set"]
|
|
476
|
+
v = result["value"]
|
|
477
|
+
patch = result.get("patch", "")
|
|
478
|
+
return f"{_c(GREEN, 'OK')} {patch}: {k} = {v}"
|
|
479
|
+
# Get all metadata
|
|
480
|
+
meta = result.get("meta")
|
|
481
|
+
if meta is not None:
|
|
482
|
+
patch = result.get("patch", "")
|
|
483
|
+
lines = [f"{_c(BOLD, patch)}"]
|
|
484
|
+
for k, v in meta.items():
|
|
485
|
+
if k == "tags":
|
|
486
|
+
v = ", ".join(v) if v else "(none)"
|
|
487
|
+
elif v is None:
|
|
488
|
+
v = "(not set)"
|
|
489
|
+
elif v == "":
|
|
490
|
+
v = "(not set)"
|
|
491
|
+
lines.append(f" {k}: {v}")
|
|
492
|
+
return "\n".join(lines)
|
|
493
|
+
# Get single key
|
|
494
|
+
k = result.get("key", "")
|
|
495
|
+
v = result.get("value", "")
|
|
496
|
+
if isinstance(v, list):
|
|
497
|
+
v = ", ".join(v) if v else "(none)"
|
|
498
|
+
elif v is None or v == "":
|
|
499
|
+
v = "(not set)"
|
|
500
|
+
return f" {k}: {v}" if k else f"{_c(GREEN, 'OK')} Done."
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _format_undo(result: dict) -> str:
|
|
504
|
+
"""Format undo result."""
|
|
505
|
+
if result.get("ok") is False:
|
|
506
|
+
err = result.get("error", "Undo failed.")
|
|
507
|
+
return f"{_c(RED, 'x')} {err}"
|
|
508
|
+
msg = result.get("message", "")
|
|
509
|
+
if msg:
|
|
510
|
+
return f"{_c(GREEN, 'OK')} {msg}"
|
|
511
|
+
restored = result.get("restored_to", "")[:7]
|
|
512
|
+
return f"{_c(GREEN, 'OK')} Restored to {restored}"
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _format_setup(result: dict) -> str:
|
|
516
|
+
"""Setup writes its own interactive output to stderr; nothing extra needed."""
|
|
517
|
+
return ""
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _format_generic(result: dict) -> str:
|
|
521
|
+
"""Fallback formatter: show message or OK."""
|
|
522
|
+
if result.get("ok") is False:
|
|
523
|
+
err = result.get("error", "Operation failed.")
|
|
524
|
+
return f"{_c(RED, 'x')} {err}"
|
|
525
|
+
msg = result.get("message", "")
|
|
526
|
+
if msg:
|
|
527
|
+
return f"{_c(GREEN, 'OK')} {msg}"
|
|
528
|
+
return f"{_c(GREEN, 'OK')} Done."
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ─── Help text ───────────────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def show_help() -> str:
|
|
535
|
+
"""Return the help text, mimicking the original Bash version."""
|
|
536
|
+
c = _colors_enabled()
|
|
537
|
+
b = BOLD if c else ""
|
|
538
|
+
cy = CYAN if c else ""
|
|
539
|
+
dm = DIM if c else ""
|
|
540
|
+
r = RESET if c else ""
|
|
541
|
+
|
|
542
|
+
return f"""
|
|
543
|
+
bingo-light — AI-native fork maintenance tool.
|
|
544
|
+
|
|
545
|
+
Manages your customizations as a clean patch stack on top of upstream,
|
|
546
|
+
so syncing with upstream is always a one-command operation.
|
|
547
|
+
|
|
548
|
+
{b}Usage:{r} bingo-light <command> [options]
|
|
549
|
+
|
|
550
|
+
{b}Setup:{r}
|
|
551
|
+
{cy}init{r} <upstream-url> [branch] Initialize fork tracking
|
|
552
|
+
{cy}setup{r} Configure MCP for AI tools (interactive)
|
|
553
|
+
|
|
554
|
+
{b}Patch Management:{r}
|
|
555
|
+
{cy}patch new{r} <name> Create a new patch
|
|
556
|
+
{cy}patch list{r} [-v] List all patches
|
|
557
|
+
{cy}patch show{r} <name|index> Show patch diff
|
|
558
|
+
{cy}patch edit{r} <name|index> Amend an existing patch
|
|
559
|
+
{cy}patch drop{r} <name|index> Remove a patch
|
|
560
|
+
{cy}patch export{r} [dir] Export patches as .patch files
|
|
561
|
+
{cy}patch import{r} <file|dir> Import .patch files
|
|
562
|
+
{cy}patch reorder{r} Reorder patch stack
|
|
563
|
+
{cy}patch squash{r} <idx1> <idx2> Merge two patches
|
|
564
|
+
{cy}patch meta{r} <name> [key] [val] Get/set patch metadata
|
|
565
|
+
|
|
566
|
+
{b}Sync with Upstream:{r}
|
|
567
|
+
{cy}sync{r} [--dry-run] [--force] [--test] Rebase patches onto latest upstream
|
|
568
|
+
{cy}smart-sync{r} One-shot sync: auto-resolves conflicts via rerere
|
|
569
|
+
{cy}undo{r} Undo last sync
|
|
570
|
+
|
|
571
|
+
{b}Monitoring:{r}
|
|
572
|
+
{cy}status{r} Health check & conflict prediction
|
|
573
|
+
{cy}doctor{r} Diagnose setup issues
|
|
574
|
+
{cy}diff{r} Show all changes vs upstream
|
|
575
|
+
{cy}log{r} Show sync history
|
|
576
|
+
{cy}conflict-analyze{r} Analyze rebase conflicts (structured output)
|
|
577
|
+
{cy}conflict-resolve{r} <file> Resolve a conflict file and continue
|
|
578
|
+
{cy}history{r} Detailed sync history with hash mappings
|
|
579
|
+
{cy}session{r} [update] AI session notes (.bingo/session.md)
|
|
580
|
+
|
|
581
|
+
{b}Configuration:{r}
|
|
582
|
+
{cy}config{r} get|set|list Manage configuration
|
|
583
|
+
{cy}test{r} Run configured test suite
|
|
584
|
+
|
|
585
|
+
{b}Automation:{r}
|
|
586
|
+
{cy}auto-sync{r} Generate GitHub Actions workflow
|
|
587
|
+
|
|
588
|
+
{b}Multi-repo:{r}
|
|
589
|
+
{cy}workspace{r} init|add|remove|status|sync|list Multi-repo management
|
|
590
|
+
|
|
591
|
+
{b}Quick Start:{r}
|
|
592
|
+
|
|
593
|
+
{dm}# 1. Fork a project on GitHub, clone your fork{r}
|
|
594
|
+
git clone https://github.com/YOU/project.git
|
|
595
|
+
cd project
|
|
596
|
+
|
|
597
|
+
{dm}# 2. Initialize bingo-light{r}
|
|
598
|
+
bingo-light init https://github.com/ORIGINAL/project.git
|
|
599
|
+
|
|
600
|
+
{dm}# 3. Make changes and create patches{r}
|
|
601
|
+
vim src/feature.py
|
|
602
|
+
bingo-light patch new my-custom-feature
|
|
603
|
+
|
|
604
|
+
{dm}# 4. Later, sync with upstream{r}
|
|
605
|
+
bingo-light sync
|
|
606
|
+
|
|
607
|
+
{dm}Version {VERSION}{r}
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# ─── Argument parsing ────────────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
class _BingoParser(argparse.ArgumentParser):
|
|
615
|
+
"""Custom parser that emits bingo-light style errors instead of argparse defaults."""
|
|
616
|
+
|
|
617
|
+
def error(self, message: str) -> None:
|
|
618
|
+
# Detect if --json was in sys.argv
|
|
619
|
+
json_mode = "--json" in sys.argv
|
|
620
|
+
# Rewrite argparse "invalid choice" to include "unknown" keyword for test compat
|
|
621
|
+
if "invalid choice:" in message:
|
|
622
|
+
# Extract the bad command name from argparse message
|
|
623
|
+
import re as _re
|
|
624
|
+
m = _re.search(r"invalid choice: '([^']+)'", message)
|
|
625
|
+
if m:
|
|
626
|
+
message = f"Unknown command: {m.group(1)}"
|
|
627
|
+
_error_exit(
|
|
628
|
+
f"{message}. Run 'bingo-light --help' for usage.",
|
|
629
|
+
json_mode,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
634
|
+
"""Build the CLI argument parser with all subcommands."""
|
|
635
|
+
parser = _BingoParser(
|
|
636
|
+
prog="bingo-light",
|
|
637
|
+
description="AI-native fork maintenance tool.",
|
|
638
|
+
add_help=False,
|
|
639
|
+
)
|
|
640
|
+
# Note: --json, --yes, --version, --help are pre-extracted in main()
|
|
641
|
+
# before argparse runs. They are NOT defined here.
|
|
642
|
+
|
|
643
|
+
sub = parser.add_subparsers(dest="command")
|
|
644
|
+
|
|
645
|
+
# init
|
|
646
|
+
p_init = sub.add_parser("init", add_help=False)
|
|
647
|
+
p_init.add_argument("upstream_url")
|
|
648
|
+
p_init.add_argument("branch", nargs="?", default="")
|
|
649
|
+
|
|
650
|
+
# status
|
|
651
|
+
sub.add_parser("status", aliases=["st"], add_help=False)
|
|
652
|
+
|
|
653
|
+
# sync
|
|
654
|
+
p_sync = sub.add_parser("sync", aliases=["s"], add_help=False)
|
|
655
|
+
p_sync.add_argument("--dry-run", dest="dry_run", action="store_true")
|
|
656
|
+
p_sync.add_argument("--force", action="store_true")
|
|
657
|
+
p_sync.add_argument("--test", action="store_true")
|
|
658
|
+
|
|
659
|
+
# smart-sync
|
|
660
|
+
sub.add_parser("smart-sync", add_help=False)
|
|
661
|
+
|
|
662
|
+
# undo
|
|
663
|
+
sub.add_parser("undo", add_help=False)
|
|
664
|
+
|
|
665
|
+
# diff
|
|
666
|
+
sub.add_parser("diff", aliases=["d"], add_help=False)
|
|
667
|
+
|
|
668
|
+
# doctor
|
|
669
|
+
sub.add_parser("doctor", add_help=False)
|
|
670
|
+
|
|
671
|
+
# log
|
|
672
|
+
sub.add_parser("log", add_help=False)
|
|
673
|
+
|
|
674
|
+
# history
|
|
675
|
+
sub.add_parser("history", add_help=False)
|
|
676
|
+
|
|
677
|
+
# conflict-analyze
|
|
678
|
+
sub.add_parser("conflict-analyze", add_help=False)
|
|
679
|
+
|
|
680
|
+
# conflict-resolve
|
|
681
|
+
cr = sub.add_parser("conflict-resolve", add_help=False)
|
|
682
|
+
cr.add_argument("resolve_file", nargs="?", default="")
|
|
683
|
+
cr.add_argument("--content-stdin", action="store_true")
|
|
684
|
+
|
|
685
|
+
# session
|
|
686
|
+
p_session = sub.add_parser("session", add_help=False)
|
|
687
|
+
p_session.add_argument("session_action", nargs="?", default="")
|
|
688
|
+
|
|
689
|
+
# config
|
|
690
|
+
p_config = sub.add_parser("config", add_help=False)
|
|
691
|
+
p_config.add_argument("config_action", choices=["get", "set", "list"])
|
|
692
|
+
p_config.add_argument("config_key", nargs="?", default="")
|
|
693
|
+
p_config.add_argument("config_value", nargs="?", default="")
|
|
694
|
+
|
|
695
|
+
# test
|
|
696
|
+
sub.add_parser("test", add_help=False)
|
|
697
|
+
|
|
698
|
+
# auto-sync
|
|
699
|
+
p_auto = sub.add_parser("auto-sync", add_help=False)
|
|
700
|
+
p_auto.add_argument("--schedule", default="daily")
|
|
701
|
+
|
|
702
|
+
# patch
|
|
703
|
+
p_patch = sub.add_parser("patch", aliases=["p"], add_help=False)
|
|
704
|
+
patch_sub = p_patch.add_subparsers(dest="patch_command")
|
|
705
|
+
|
|
706
|
+
pp_new = patch_sub.add_parser("new", add_help=False)
|
|
707
|
+
pp_new.add_argument("patch_name")
|
|
708
|
+
|
|
709
|
+
pp_list = patch_sub.add_parser("list", add_help=False)
|
|
710
|
+
pp_list.add_argument("-v", "--verbose", action="store_true")
|
|
711
|
+
|
|
712
|
+
pp_show = patch_sub.add_parser("show", add_help=False)
|
|
713
|
+
pp_show.add_argument("target")
|
|
714
|
+
|
|
715
|
+
pp_drop = patch_sub.add_parser("drop", add_help=False)
|
|
716
|
+
pp_drop.add_argument("target")
|
|
717
|
+
|
|
718
|
+
pp_edit = patch_sub.add_parser("edit", add_help=False)
|
|
719
|
+
pp_edit.add_argument("target")
|
|
720
|
+
|
|
721
|
+
pp_export = patch_sub.add_parser("export", add_help=False)
|
|
722
|
+
pp_export.add_argument("output_dir", nargs="?", default=".bl-patches")
|
|
723
|
+
|
|
724
|
+
pp_import = patch_sub.add_parser("import", add_help=False)
|
|
725
|
+
pp_import.add_argument("import_path")
|
|
726
|
+
|
|
727
|
+
pp_reorder = patch_sub.add_parser("reorder", add_help=False)
|
|
728
|
+
pp_reorder.add_argument("--order", default="")
|
|
729
|
+
pp_reorder.add_argument("order_positional", nargs="?", default="")
|
|
730
|
+
|
|
731
|
+
pp_squash = patch_sub.add_parser("squash", add_help=False)
|
|
732
|
+
pp_squash.add_argument("idx1", type=int)
|
|
733
|
+
pp_squash.add_argument("idx2", type=int)
|
|
734
|
+
|
|
735
|
+
pp_meta = patch_sub.add_parser("meta", add_help=False)
|
|
736
|
+
pp_meta.add_argument("meta_target")
|
|
737
|
+
pp_meta.add_argument("meta_key", nargs="?", default="")
|
|
738
|
+
pp_meta.add_argument("meta_value", nargs="?", default="")
|
|
739
|
+
|
|
740
|
+
# workspace
|
|
741
|
+
p_ws = sub.add_parser("workspace", aliases=["ws"], add_help=False)
|
|
742
|
+
ws_sub = p_ws.add_subparsers(dest="ws_command")
|
|
743
|
+
|
|
744
|
+
ws_sub.add_parser("init", add_help=False)
|
|
745
|
+
|
|
746
|
+
ws_add = ws_sub.add_parser("add", add_help=False)
|
|
747
|
+
ws_add.add_argument("ws_path", nargs="?", default="")
|
|
748
|
+
ws_add.add_argument("--alias", default="")
|
|
749
|
+
|
|
750
|
+
ws_sub.add_parser("list", add_help=False)
|
|
751
|
+
ws_sub.add_parser("sync", add_help=False)
|
|
752
|
+
ws_sub.add_parser("status", add_help=False)
|
|
753
|
+
|
|
754
|
+
ws_remove = ws_sub.add_parser("remove", add_help=False)
|
|
755
|
+
ws_remove.add_argument("ws_target")
|
|
756
|
+
|
|
757
|
+
# setup
|
|
758
|
+
p_setup = sub.add_parser("setup", add_help=False)
|
|
759
|
+
p_setup.add_argument("--no-completions", dest="no_completions", action="store_true")
|
|
760
|
+
|
|
761
|
+
# help
|
|
762
|
+
sub.add_parser("help", add_help=False)
|
|
763
|
+
|
|
764
|
+
return parser
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
# ─── Dispatch ────────────────────────────────────────────────────────────────
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def dispatch(args: argparse.Namespace, json_mode: bool) -> Optional[dict]:
|
|
771
|
+
"""Dispatch a parsed command to the Repo method and return the result dict."""
|
|
772
|
+
cmd = args.command
|
|
773
|
+
|
|
774
|
+
# setup doesn't need a Repo (works outside git repos)
|
|
775
|
+
if cmd == "setup":
|
|
776
|
+
return run_setup(
|
|
777
|
+
yes=getattr(args, "yes_mode", False),
|
|
778
|
+
json_mode=json_mode,
|
|
779
|
+
no_completions=getattr(args, "no_completions", False),
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
repo = Repo()
|
|
783
|
+
|
|
784
|
+
if cmd == "init":
|
|
785
|
+
return repo.init(args.upstream_url, args.branch)
|
|
786
|
+
|
|
787
|
+
if cmd in ("status", "st"):
|
|
788
|
+
return repo.status()
|
|
789
|
+
|
|
790
|
+
if cmd in ("sync", "s"):
|
|
791
|
+
return repo.sync(
|
|
792
|
+
dry_run=args.dry_run,
|
|
793
|
+
force=args.force or getattr(args, "yes_mode", False),
|
|
794
|
+
test=args.test,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
if cmd == "smart-sync":
|
|
798
|
+
return repo.smart_sync()
|
|
799
|
+
|
|
800
|
+
if cmd == "undo":
|
|
801
|
+
return repo.undo()
|
|
802
|
+
|
|
803
|
+
if cmd in ("diff", "d"):
|
|
804
|
+
return repo.diff()
|
|
805
|
+
|
|
806
|
+
if cmd == "doctor":
|
|
807
|
+
return repo.doctor()
|
|
808
|
+
|
|
809
|
+
if cmd == "log":
|
|
810
|
+
return repo.history()
|
|
811
|
+
|
|
812
|
+
if cmd == "history":
|
|
813
|
+
return repo.history()
|
|
814
|
+
|
|
815
|
+
if cmd == "conflict-analyze":
|
|
816
|
+
return repo.conflict_analyze()
|
|
817
|
+
|
|
818
|
+
if cmd == "conflict-resolve":
|
|
819
|
+
content = ""
|
|
820
|
+
if args.content_stdin:
|
|
821
|
+
import sys as _sys
|
|
822
|
+
content = _sys.stdin.read()
|
|
823
|
+
return repo.conflict_resolve(args.resolve_file, content)
|
|
824
|
+
|
|
825
|
+
if cmd == "session":
|
|
826
|
+
update = (args.session_action == "update")
|
|
827
|
+
return repo.session(update=update)
|
|
828
|
+
|
|
829
|
+
if cmd == "config":
|
|
830
|
+
if args.config_action == "get":
|
|
831
|
+
if not args.config_key:
|
|
832
|
+
raise BingoError("config get requires a key.")
|
|
833
|
+
return repo.config_get(args.config_key)
|
|
834
|
+
if args.config_action == "set":
|
|
835
|
+
if not args.config_key or not args.config_value:
|
|
836
|
+
raise BingoError("config set requires <key> <value>.")
|
|
837
|
+
return repo.config_set(args.config_key, args.config_value)
|
|
838
|
+
if args.config_action == "list":
|
|
839
|
+
return repo.config_list()
|
|
840
|
+
|
|
841
|
+
if cmd == "test":
|
|
842
|
+
return repo.test()
|
|
843
|
+
|
|
844
|
+
if cmd == "auto-sync":
|
|
845
|
+
return repo.auto_sync(schedule=getattr(args, "schedule", "daily"))
|
|
846
|
+
|
|
847
|
+
if cmd in ("patch", "p"):
|
|
848
|
+
return _dispatch_patch(args, repo)
|
|
849
|
+
|
|
850
|
+
if cmd in ("workspace", "ws"):
|
|
851
|
+
return _dispatch_workspace(args, repo)
|
|
852
|
+
|
|
853
|
+
return None
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _dispatch_patch(args: argparse.Namespace, repo: Repo) -> dict:
|
|
857
|
+
"""Dispatch patch subcommands."""
|
|
858
|
+
pcmd = args.patch_command
|
|
859
|
+
if not pcmd:
|
|
860
|
+
raise BingoError(
|
|
861
|
+
"patch requires a subcommand: new, list, show, edit, drop, "
|
|
862
|
+
"export, import, reorder, squash, meta"
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
if pcmd == "new":
|
|
866
|
+
desc = os.environ.get("BINGO_DESCRIPTION", "")
|
|
867
|
+
return repo.patch_new(args.patch_name, description=desc)
|
|
868
|
+
|
|
869
|
+
if pcmd == "list":
|
|
870
|
+
return repo.patch_list(verbose=args.verbose)
|
|
871
|
+
|
|
872
|
+
if pcmd == "show":
|
|
873
|
+
return repo.patch_show(args.target)
|
|
874
|
+
|
|
875
|
+
if pcmd == "drop":
|
|
876
|
+
return repo.patch_drop(args.target)
|
|
877
|
+
|
|
878
|
+
if pcmd == "edit":
|
|
879
|
+
return repo.patch_edit(args.target)
|
|
880
|
+
|
|
881
|
+
if pcmd == "export":
|
|
882
|
+
return repo.patch_export(args.output_dir)
|
|
883
|
+
|
|
884
|
+
if pcmd == "import":
|
|
885
|
+
return repo.patch_import(args.import_path)
|
|
886
|
+
|
|
887
|
+
if pcmd == "reorder":
|
|
888
|
+
order = args.order or getattr(args, "order_positional", "")
|
|
889
|
+
return repo.patch_reorder(order=order)
|
|
890
|
+
|
|
891
|
+
if pcmd == "squash":
|
|
892
|
+
return repo.patch_squash(args.idx1, args.idx2)
|
|
893
|
+
|
|
894
|
+
if pcmd == "meta":
|
|
895
|
+
meta_key = args.meta_key or ""
|
|
896
|
+
meta_value = args.meta_value or ""
|
|
897
|
+
# Support "set-reason VALUE" as shorthand for "reason VALUE"
|
|
898
|
+
if meta_key.startswith("set-") and meta_value:
|
|
899
|
+
meta_key = meta_key[4:] # "set-reason" -> "reason"
|
|
900
|
+
return repo.patch_meta(
|
|
901
|
+
args.meta_target,
|
|
902
|
+
key=meta_key,
|
|
903
|
+
value=meta_value,
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
raise BingoError(f"Unknown patch subcommand: {pcmd}")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _dispatch_workspace(args: argparse.Namespace, repo: Repo) -> dict:
|
|
910
|
+
"""Dispatch workspace subcommands."""
|
|
911
|
+
wcmd = args.ws_command
|
|
912
|
+
if not wcmd:
|
|
913
|
+
raise BingoError(
|
|
914
|
+
"workspace requires a subcommand: init, add, remove, list, sync, status"
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
if wcmd == "init":
|
|
918
|
+
return repo.workspace_init()
|
|
919
|
+
|
|
920
|
+
if wcmd == "add":
|
|
921
|
+
return repo.workspace_add(
|
|
922
|
+
repo_path=args.ws_path,
|
|
923
|
+
alias=args.alias,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
if wcmd == "list":
|
|
927
|
+
return repo.workspace_list()
|
|
928
|
+
|
|
929
|
+
if wcmd == "sync":
|
|
930
|
+
return repo.workspace_sync()
|
|
931
|
+
|
|
932
|
+
if wcmd == "status":
|
|
933
|
+
return repo.workspace_status()
|
|
934
|
+
|
|
935
|
+
if wcmd == "remove":
|
|
936
|
+
return repo.workspace_remove(args.ws_target)
|
|
937
|
+
|
|
938
|
+
raise BingoError(f"Unknown workspace subcommand: {wcmd}")
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
# ─── Formatter dispatch ─────────────────────────────────────────────────────
|
|
942
|
+
|
|
943
|
+
_FORMATTERS: Dict[str, Any] = {
|
|
944
|
+
"init": _format_init,
|
|
945
|
+
"status": _format_status,
|
|
946
|
+
"st": _format_status,
|
|
947
|
+
"sync": _format_sync,
|
|
948
|
+
"s": _format_sync,
|
|
949
|
+
"doctor": _format_doctor,
|
|
950
|
+
"diff": _format_diff,
|
|
951
|
+
"d": _format_diff,
|
|
952
|
+
"log": _format_log,
|
|
953
|
+
"history": _format_history,
|
|
954
|
+
"conflict-analyze": _format_conflict_analyze,
|
|
955
|
+
"conflict-resolve": _format_conflict_resolve,
|
|
956
|
+
"session": _format_session,
|
|
957
|
+
"test": _format_test,
|
|
958
|
+
"auto-sync": _format_auto_sync,
|
|
959
|
+
"undo": _format_undo,
|
|
960
|
+
"smart-sync": _format_smart_sync,
|
|
961
|
+
"setup": _format_setup,
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _get_formatter(args: argparse.Namespace):
|
|
966
|
+
"""Return the appropriate human-output formatter for a command."""
|
|
967
|
+
cmd = args.command
|
|
968
|
+
|
|
969
|
+
# patch subcommands
|
|
970
|
+
if cmd in ("patch", "p"):
|
|
971
|
+
pcmd = getattr(args, "patch_command", "")
|
|
972
|
+
if pcmd == "list":
|
|
973
|
+
return _format_patch_list
|
|
974
|
+
if pcmd == "show":
|
|
975
|
+
return _format_patch_show
|
|
976
|
+
if pcmd == "new":
|
|
977
|
+
return _format_patch_new
|
|
978
|
+
if pcmd == "drop":
|
|
979
|
+
return _format_patch_drop
|
|
980
|
+
if pcmd == "export":
|
|
981
|
+
return _format_patch_export
|
|
982
|
+
if pcmd == "import":
|
|
983
|
+
return _format_patch_import
|
|
984
|
+
if pcmd == "meta":
|
|
985
|
+
return _format_patch_meta
|
|
986
|
+
return _format_generic
|
|
987
|
+
|
|
988
|
+
# workspace subcommands
|
|
989
|
+
if cmd in ("workspace", "ws"):
|
|
990
|
+
return _format_workspace
|
|
991
|
+
|
|
992
|
+
# config
|
|
993
|
+
if cmd == "config":
|
|
994
|
+
return _format_config
|
|
995
|
+
|
|
996
|
+
return _FORMATTERS.get(cmd, _format_generic)
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def main() -> None:
|
|
1003
|
+
"""Entry point."""
|
|
1004
|
+
# ── Pre-extract global flags before argparse (subparsers don't inherit them) ──
|
|
1005
|
+
raw_args = sys.argv[1:]
|
|
1006
|
+
json_mode = False
|
|
1007
|
+
yes_mode = False
|
|
1008
|
+
show_version = False
|
|
1009
|
+
show_help_flag = False
|
|
1010
|
+
cleaned = []
|
|
1011
|
+
for arg in raw_args:
|
|
1012
|
+
if arg == "--json":
|
|
1013
|
+
json_mode = True
|
|
1014
|
+
elif arg in ("--yes", "-y"):
|
|
1015
|
+
yes_mode = True
|
|
1016
|
+
elif arg in ("--version",):
|
|
1017
|
+
show_version = True
|
|
1018
|
+
elif arg in ("--help", "-h"):
|
|
1019
|
+
show_help_flag = True
|
|
1020
|
+
else:
|
|
1021
|
+
cleaned.append(arg)
|
|
1022
|
+
|
|
1023
|
+
# Auto-enable yes when stdin is not a TTY (AI agent / pipe)
|
|
1024
|
+
if not sys.stdin.isatty():
|
|
1025
|
+
yes_mode = True
|
|
1026
|
+
|
|
1027
|
+
# --version (before argparse)
|
|
1028
|
+
if show_version:
|
|
1029
|
+
if json_mode:
|
|
1030
|
+
_json_print({"ok": True, "version": VERSION})
|
|
1031
|
+
else:
|
|
1032
|
+
print(f"bingo-light {VERSION}")
|
|
1033
|
+
return
|
|
1034
|
+
|
|
1035
|
+
# --help or no args or 'help' command
|
|
1036
|
+
if show_help_flag or not cleaned or (cleaned and cleaned[0] == "help"):
|
|
1037
|
+
if json_mode:
|
|
1038
|
+
_json_print({"ok": True, "help": True, "version": VERSION})
|
|
1039
|
+
else:
|
|
1040
|
+
print(show_help())
|
|
1041
|
+
return
|
|
1042
|
+
|
|
1043
|
+
# ── Parse remaining args with argparse ──
|
|
1044
|
+
parser = build_parser()
|
|
1045
|
+
args, unknown = parser.parse_known_args(cleaned)
|
|
1046
|
+
args.json_mode = json_mode
|
|
1047
|
+
args.yes_mode = yes_mode
|
|
1048
|
+
|
|
1049
|
+
# No command after parsing
|
|
1050
|
+
if args.command is None:
|
|
1051
|
+
print(show_help())
|
|
1052
|
+
return
|
|
1053
|
+
|
|
1054
|
+
# Unknown args check
|
|
1055
|
+
if unknown:
|
|
1056
|
+
_error_exit(
|
|
1057
|
+
f"Unknown argument(s): {' '.join(unknown)}. "
|
|
1058
|
+
"Run 'bingo-light --help' for usage.",
|
|
1059
|
+
json_mode,
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
# Dispatch
|
|
1063
|
+
try:
|
|
1064
|
+
result = dispatch(args, json_mode)
|
|
1065
|
+
except BingoError as e:
|
|
1066
|
+
_error_exit(str(e), json_mode)
|
|
1067
|
+
return # unreachable, _error_exit calls sys.exit
|
|
1068
|
+
except KeyboardInterrupt:
|
|
1069
|
+
_error_exit("Interrupted.", json_mode)
|
|
1070
|
+
return
|
|
1071
|
+
|
|
1072
|
+
if result is None:
|
|
1073
|
+
_error_exit(
|
|
1074
|
+
f"Unknown command: {args.command}. Run 'bingo-light --help' for usage.",
|
|
1075
|
+
json_mode,
|
|
1076
|
+
)
|
|
1077
|
+
return
|
|
1078
|
+
|
|
1079
|
+
# Output
|
|
1080
|
+
if json_mode:
|
|
1081
|
+
_json_print(result)
|
|
1082
|
+
else:
|
|
1083
|
+
formatter = _get_formatter(args)
|
|
1084
|
+
output = formatter(result)
|
|
1085
|
+
if output:
|
|
1086
|
+
print(output)
|
|
1087
|
+
|
|
1088
|
+
# Exit non-zero if result indicates failure
|
|
1089
|
+
if isinstance(result, dict) and result.get("ok") is False:
|
|
1090
|
+
sys.exit(1)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
if __name__ == "__main__":
|
|
1094
|
+
main()
|