bingo-light 2.1.2 → 2.2.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/README.en.md +17 -7
- package/README.md +30 -4
- package/bingo-light +321 -11
- package/bingo_core/__init__.py +9 -1
- package/bingo_core/decisions.py +167 -0
- package/bingo_core/dep.py +385 -25
- package/bingo_core/dep_fork.py +268 -0
- package/bingo_core/models.py +1 -0
- package/bingo_core/repo.py +1031 -9
- package/bingo_core/semantic.py +85 -0
- package/bingo_core/state.py +1 -1
- package/bingo_core/team.py +170 -0
- package/completions/bingo-light.bash +14 -4
- package/completions/bingo-light.fish +23 -2
- package/completions/bingo-light.zsh +18 -2
- package/mcp-server.py +245 -7
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
|
11
11
|
<a href="https://github.com/DanOps-1/bingo-light/releases"><img src="https://img.shields.io/github/v/release/DanOps-1/bingo-light?label=Release&color=orange" alt="Release"></a>
|
|
12
12
|
<br>
|
|
13
|
-
<a href="#for-ai-agents"><img src="https://img.shields.io/badge/MCP_Server-
|
|
13
|
+
<a href="#for-ai-agents"><img src="https://img.shields.io/badge/MCP_Server-49_tools-blueviolet.svg" alt="MCP: 49 tools"></a>
|
|
14
14
|
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.8+-3776ab.svg" alt="Python 3.8+"></a>
|
|
15
15
|
<img src="https://img.shields.io/badge/Dependencies-Zero-brightgreen.svg" alt="Zero deps">
|
|
16
16
|
<a href="https://github.com/DanOps-1/bingo-light/stargazers"><img src="https://img.shields.io/github/stars/DanOps-1/bingo-light?style=social" alt="Stars"></a>
|
|
@@ -25,7 +25,7 @@ GitHub's "Sync fork" button breaks the moment you have customizations. `git reba
|
|
|
25
25
|
|
|
26
26
|
Your patches live as a clean, named stack on top of upstream. Syncing is `bingo-light sync`. Conflicts get remembered so you never solve the same one twice. And if something goes sideways, `bingo-light undo` puts everything back in one second.
|
|
27
27
|
|
|
28
|
-
Every command speaks JSON. The built-in MCP server gives AI agents
|
|
28
|
+
Every command speaks JSON. The built-in MCP server gives AI agents 49 tools to manage your fork autonomously -- from init through conflict resolution. No human in the loop required.
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
@@ -69,6 +69,16 @@ That's it. Three commands and your fork stays in sync forever.
|
|
|
69
69
|
|
|
70
70
|
> The AI calls `conflict-analyze --json`, reads the structured ours/theirs data, writes the merged file, and the rebase continues. No human needed.
|
|
71
71
|
|
|
72
|
+
During a rebase, `conflict-analyze` returns a full situational briefing:
|
|
73
|
+
- **`patch_intent`** — patch name, subject, full commit message, original SHA, original diff, metadata, stack position.
|
|
74
|
+
- **`verify`** — configured `test.command` + per-file syntax/parse hints by extension (`.py/.json/.yml/.yaml/.toml/.sh`).
|
|
75
|
+
- **`upstream_context`** — upstream commits touching conflicts, with author, subject, and extracted PR numbers.
|
|
76
|
+
- **`patch_dependencies`** — later patches in your stack that modify overlapping files (cascade risk).
|
|
77
|
+
- **`decision_memory`** — prior resolutions for this patch from `.bingo/decisions/`, ranked by relevance.
|
|
78
|
+
- Each `conflicts[]` entry carries **`semantic_class`**: `whitespace` / `import_reorder` / `signature_change` / `logic`.
|
|
79
|
+
|
|
80
|
+
`conflict-resolve --verify` (CLI) or `verify: true` (MCP) runs `test.command` after the final `git rebase --continue`; the result is attached as `verify_result`. `conflict-resolve` also auto-records the decision (file, semantic class, strategy) to decision memory.
|
|
81
|
+
|
|
72
82
|
---
|
|
73
83
|
|
|
74
84
|
## Key Features
|
|
@@ -93,7 +103,7 @@ That's it. Three commands and your fork stays in sync forever.
|
|
|
93
103
|
|
|
94
104
|
### For AI Agents
|
|
95
105
|
|
|
96
|
-
- :electric_plug: **MCP server (
|
|
106
|
+
- :electric_plug: **MCP server (49 tools)** -- full fork management from init through conflict resolution.
|
|
97
107
|
- :bar_chart: **`--json` on everything** -- every command returns structured JSON. Parse, don't scrape.
|
|
98
108
|
- :mute: **`--yes` flag** -- fully non-interactive. No TTY required. No prompts. Ever.
|
|
99
109
|
- :gear: **Auto-detect non-TTY** -- pipes and subprocesses trigger non-interactive mode automatically.
|
|
@@ -200,7 +210,7 @@ make install && bingo-light setup
|
|
|
200
210
|
|
|
201
211
|
## For AI Agents
|
|
202
212
|
|
|
203
|
-
bingo-light was designed from day one for AI agents. Every command speaks JSON. The MCP server exposes
|
|
213
|
+
bingo-light was designed from day one for AI agents. Every command speaks JSON. The MCP server exposes 49 tools covering the full lifecycle from `init` to `conflict-resolve`. Non-interactive mode is the default when stdin is not a TTY.
|
|
204
214
|
|
|
205
215
|
### MCP setup -- Claude Code
|
|
206
216
|
|
|
@@ -234,7 +244,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
|
234
244
|
|
|
235
245
|
**Any MCP client** (VS Code Copilot, Cursor, custom agents): connect via stdio to `python3 mcp-server.py`.
|
|
236
246
|
|
|
237
|
-
###
|
|
247
|
+
### 49 MCP Tools
|
|
238
248
|
|
|
239
249
|
| Tool | Purpose |
|
|
240
250
|
|------|---------|
|
|
@@ -424,7 +434,7 @@ StGit (649 stars) manages patch stacks but has no AI integration, no MCP server,
|
|
|
424
434
|
| Handles customizations | **Yes** | **No** | Manual | Manual | Manual |
|
|
425
435
|
| Conflict memory (rerere) | **Auto** | No | Manual | No | No |
|
|
426
436
|
| Conflict prediction | **Yes** | No | No | No | No |
|
|
427
|
-
| AI / MCP integration | **
|
|
437
|
+
| AI / MCP integration | **49 tools** | No | No | No | No |
|
|
428
438
|
| JSON output | **All commands** | No | No | No | No |
|
|
429
439
|
| Non-interactive mode | **Native** | No | Partial | Partial | Partial |
|
|
430
440
|
| Undo sync | **One command** | No | git reflog | Manual | Manual |
|
|
@@ -490,7 +500,7 @@ Yes. bingo-light uses standard git operations (fetch, rebase, push). It works wi
|
|
|
490
500
|
```
|
|
491
501
|
bingo-light CLI tool (Python 3, zero deps)
|
|
492
502
|
bingo_core/ Core library package (all business logic)
|
|
493
|
-
mcp-server.py MCP server (zero-dep Python 3,
|
|
503
|
+
mcp-server.py MCP server (zero-dep Python 3, 49 tools, JSON-RPC 2.0)
|
|
494
504
|
contrib/agent.py Advisor agent (monitors drift, auto-syncs when safe)
|
|
495
505
|
contrib/tui.py Terminal dashboard (curses TUI, real-time monitoring)
|
|
496
506
|
install.sh Installer (--yes for CI, --help for options)
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<a href="https://github.com/DanOps-1/bingo-light/actions"><img src="https://github.com/DanOps-1/bingo-light/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
10
10
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
|
11
11
|
<a href="https://github.com/DanOps-1/bingo-light/releases"><img src="https://img.shields.io/github/v/release/DanOps-1/bingo-light?label=Release&color=orange" alt="Release"></a>
|
|
12
|
-
<a href="#mcp-服务器"><img src="https://img.shields.io/badge/MCP_Server-
|
|
12
|
+
<a href="#mcp-服务器"><img src="https://img.shields.io/badge/MCP_Server-49_tools-blueviolet.svg" alt="MCP: 49 tools"></a>
|
|
13
13
|
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.8+-3776ab.svg" alt="Python 3.8+"></a>
|
|
14
14
|
<img src="https://img.shields.io/badge/Dependencies-Zero-brightgreen.svg" alt="Zero deps">
|
|
15
15
|
<a href="https://github.com/DanOps-1/bingo-light/stargazers"><img src="https://img.shields.io/github/stars/DanOps-1/bingo-light?style=social" alt="Stars"></a>
|
|
@@ -168,10 +168,36 @@ cd bingo-light && make install && bingo-light setup
|
|
|
168
168
|
"theirs": "... 你的补丁版本 ...",
|
|
169
169
|
"hint": "上游重构了调度器核心;补丁需要适配新结构。"
|
|
170
170
|
}
|
|
171
|
-
]
|
|
171
|
+
],
|
|
172
|
+
"patch_intent": {
|
|
173
|
+
"name": "custom-scheduler",
|
|
174
|
+
"subject": "...",
|
|
175
|
+
"message": "完整 commit 消息",
|
|
176
|
+
"original_sha": "a1b2c3d...",
|
|
177
|
+
"original_diff": "diff --git ...",
|
|
178
|
+
"meta": {"reason": "...", "tags": [], "status": "permanent"},
|
|
179
|
+
"stack_position": {"index": 3, "total": 7}
|
|
180
|
+
},
|
|
181
|
+
"verify": {
|
|
182
|
+
"test_command": "make test",
|
|
183
|
+
"file_hints": [
|
|
184
|
+
{"file": "kernel/sched/core.c", "command": "bash -n ...", "kind": "syntax"}
|
|
185
|
+
]
|
|
186
|
+
}
|
|
172
187
|
}
|
|
173
188
|
```
|
|
174
189
|
|
|
190
|
+
`conflict-analyze` 在 rebase 中返回完整态势简报:
|
|
191
|
+
|
|
192
|
+
- **`patch_intent`**:补丁意图(原始 commit、diff、metadata、栈位置)
|
|
193
|
+
- **`verify`**:配置的 `test.command` + 按扩展名的逐文件校验命令
|
|
194
|
+
- **`upstream_context`**:触发冲突的上游 commits(作者、主题、自动抽取的 PR 号)
|
|
195
|
+
- **`patch_dependencies`**:栈中后续补丁是否触及相同文件(cascade 风险)
|
|
196
|
+
- **`decision_memory`**:该补丁的历史解决方案(`.bingo/decisions/`)
|
|
197
|
+
- 每条 `conflicts[]` 都带 **`semantic_class`**:`whitespace` / `import_reorder` / `signature_change` / `logic`
|
|
198
|
+
|
|
199
|
+
`conflict-resolve --verify` 在最终 `git rebase --continue` 完成后自动跑 `test.command`,结果挂在 `verify_result` 字段;`conflict-resolve` 还会自动把本次决策(file, semantic class, strategy)写入 decision memory。
|
|
200
|
+
|
|
175
201
|
</details>
|
|
176
202
|
|
|
177
203
|
<details>
|
|
@@ -197,7 +223,7 @@ Claude Code:
|
|
|
197
223
|
```console
|
|
198
224
|
$ bingo-light setup
|
|
199
225
|
|
|
200
|
-
◆ bingo-light setup v2.
|
|
226
|
+
◆ bingo-light setup v2.x.x
|
|
201
227
|
│
|
|
202
228
|
◆ MCP Server
|
|
203
229
|
│ Connect bingo-light tools to your AI coding assistants
|
|
@@ -434,7 +460,7 @@ bingo-light help 打印帮助
|
|
|
434
460
|
|
|
435
461
|
| 集成方式 | 适用场景 | 示例 |
|
|
436
462
|
|---------|---------|------|
|
|
437
|
-
| **MCP** (
|
|
463
|
+
| **MCP** (49 tools) | Claude Code / Cursor / Windsurf 等 | `bingo-light setup` 自动配 |
|
|
438
464
|
| **CLI `--json`** | 任何能跑 shell 的 AI | `bingo-light sync --json --yes` |
|
|
439
465
|
| **Skill** | Claude Code / Continue / Gemini 等 | `/bingo` 教 AI 用法 |
|
|
440
466
|
|
package/bingo-light
CHANGED
|
@@ -234,6 +234,7 @@ def _format_conflict_analyze(result: dict) -> str:
|
|
|
234
234
|
conflicts = result.get("conflicts", [])
|
|
235
235
|
if not conflicts:
|
|
236
236
|
return f"{_c(GREEN, 'OK')} No conflicts detected."
|
|
237
|
+
|
|
237
238
|
lines = [f"{_c(YELLOW, '!')} {len(conflicts)} conflicting file(s):"]
|
|
238
239
|
for c in conflicts:
|
|
239
240
|
f = c.get("file", "?")
|
|
@@ -241,6 +242,85 @@ def _format_conflict_analyze(result: dict) -> str:
|
|
|
241
242
|
lines.append(f" {_c(RED, f)}")
|
|
242
243
|
if hint:
|
|
243
244
|
lines.append(f" hint: {hint}")
|
|
245
|
+
|
|
246
|
+
intent = result.get("patch_intent")
|
|
247
|
+
if intent and intent.get("name"):
|
|
248
|
+
lines.append("")
|
|
249
|
+
lines.append(
|
|
250
|
+
f"{_c(BOLD, 'Patch intent:')} "
|
|
251
|
+
f"{intent['name']} \u2014 {intent.get('subject', '')}"
|
|
252
|
+
)
|
|
253
|
+
meta = intent.get("meta") or {}
|
|
254
|
+
if meta.get("reason"):
|
|
255
|
+
lines.append(f" reason: {meta['reason']}")
|
|
256
|
+
if meta.get("tags"):
|
|
257
|
+
lines.append(f" tags: {', '.join(meta['tags'])}")
|
|
258
|
+
sp = intent.get("stack_position")
|
|
259
|
+
if sp:
|
|
260
|
+
lines.append(f" position: {sp['index']}/{sp['total']}")
|
|
261
|
+
|
|
262
|
+
verify = result.get("verify")
|
|
263
|
+
if verify:
|
|
264
|
+
lines.append("")
|
|
265
|
+
lines.append(f"{_c(BOLD, 'Verify suggestions:')}")
|
|
266
|
+
tc = verify.get("test_command")
|
|
267
|
+
if tc:
|
|
268
|
+
lines.append(f" test.command: {tc}")
|
|
269
|
+
for h in verify.get("file_hints", []):
|
|
270
|
+
lines.append(f" [{h['kind']}] {h['command']}")
|
|
271
|
+
|
|
272
|
+
deps = result.get("patch_dependencies")
|
|
273
|
+
if deps and deps.get("dependents"):
|
|
274
|
+
lines.append("")
|
|
275
|
+
dlist = deps["dependents"]
|
|
276
|
+
lines.append(
|
|
277
|
+
f"{_c(BOLD, 'Cascade risk:')} "
|
|
278
|
+
f"{len(dlist)} later patch(es) touch overlapping files"
|
|
279
|
+
)
|
|
280
|
+
for d in dlist[:5]:
|
|
281
|
+
files = ", ".join(d.get("overlapping_files", []))
|
|
282
|
+
lines.append(
|
|
283
|
+
f" #{d['position']} {d['name']}: {d.get('subject', '')} "
|
|
284
|
+
f"\u2014 {_c(YELLOW, files)}"
|
|
285
|
+
)
|
|
286
|
+
if len(dlist) > 5:
|
|
287
|
+
lines.append(f" {_c(DIM, f'... {len(dlist) - 5} more')}")
|
|
288
|
+
|
|
289
|
+
dm = result.get("decision_memory")
|
|
290
|
+
if dm and dm.get("entries"):
|
|
291
|
+
lines.append("")
|
|
292
|
+
lines.append(f"{_c(BOLD, 'Decision memory:')} prior resolutions for this patch")
|
|
293
|
+
for e in dm["entries"]:
|
|
294
|
+
lines.append(f" {_c(CYAN, e['file'])} ({e['semantic_class']}):")
|
|
295
|
+
for prev in e.get("previous_decisions", [])[:3]:
|
|
296
|
+
strat = prev.get("resolution_strategy", "?")
|
|
297
|
+
subj = prev.get("upstream_subject") or "(no subject)"
|
|
298
|
+
ts = prev.get("timestamp", "")
|
|
299
|
+
rel = prev.get("relevance", "")
|
|
300
|
+
lines.append(
|
|
301
|
+
f" [{strat}] {subj} {_c(DIM, f'({rel}, {ts})')}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
uc = result.get("upstream_context")
|
|
305
|
+
if uc:
|
|
306
|
+
lines.append("")
|
|
307
|
+
total = uc.get("total_commits", 0)
|
|
308
|
+
touching = uc.get("commits_touching_conflicts", [])
|
|
309
|
+
lines.append(
|
|
310
|
+
f"{_c(BOLD, 'Upstream context:')} "
|
|
311
|
+
f"{uc.get('range', '?')} "
|
|
312
|
+
f"({total} commits, {len(touching)} touching conflicts)"
|
|
313
|
+
)
|
|
314
|
+
for commit in touching[:5]:
|
|
315
|
+
short = commit.get("short_sha", "?")
|
|
316
|
+
subj = commit.get("subject", "")
|
|
317
|
+
author = commit.get("author", "")
|
|
318
|
+
pr = commit.get("pr")
|
|
319
|
+
pr_tag = f" #{pr}" if pr else ""
|
|
320
|
+
lines.append(f" {_c(DIM, short)} {subj}{pr_tag} \u2014 {author}")
|
|
321
|
+
if len(touching) > 5:
|
|
322
|
+
lines.append(f" {_c(DIM, f'... {len(touching) - 5} more')}")
|
|
323
|
+
|
|
244
324
|
return "\n".join(lines)
|
|
245
325
|
|
|
246
326
|
|
|
@@ -261,10 +341,18 @@ def _format_conflict_resolve(result: dict) -> str:
|
|
|
261
341
|
)
|
|
262
342
|
|
|
263
343
|
if result.get("sync_complete"):
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
344
|
+
out = f"{_c(GREEN, 'OK')} Resolved {resolved} \u2014 sync complete!"
|
|
345
|
+
vr = result.get("verify_result")
|
|
346
|
+
if vr:
|
|
347
|
+
if vr.get("skipped"):
|
|
348
|
+
out += f"\n verify: skipped ({vr.get('reason', '')})"
|
|
349
|
+
elif vr.get("test") == "pass":
|
|
350
|
+
out += f"\n {_c(GREEN, 'verify: PASS')} ({vr.get('command', '')})"
|
|
351
|
+
elif vr.get("test") == "fail":
|
|
352
|
+
out += f"\n {_c(RED, 'verify: FAIL')} ({vr.get('command', '')})"
|
|
353
|
+
else:
|
|
354
|
+
out += f"\n verify: {vr.get('test', '?')}"
|
|
355
|
+
return out
|
|
268
356
|
|
|
269
357
|
if result.get("conflict"):
|
|
270
358
|
patch = result.get("current_patch", "")
|
|
@@ -284,10 +372,18 @@ def _format_conflict_resolve(result: dict) -> str:
|
|
|
284
372
|
return "\n".join(lines)
|
|
285
373
|
|
|
286
374
|
if result.get("rebase_continued"):
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
375
|
+
out = f"{_c(GREEN, 'OK')} Resolved {resolved} \u2014 rebase continued."
|
|
376
|
+
vr = result.get("verify_result")
|
|
377
|
+
if vr:
|
|
378
|
+
if vr.get("skipped"):
|
|
379
|
+
out += f"\n verify: skipped ({vr.get('reason', '')})"
|
|
380
|
+
elif vr.get("test") == "pass":
|
|
381
|
+
out += f"\n {_c(GREEN, 'verify: PASS')} ({vr.get('command', '')})"
|
|
382
|
+
elif vr.get("test") == "fail":
|
|
383
|
+
out += f"\n {_c(RED, 'verify: FAIL')} ({vr.get('command', '')})"
|
|
384
|
+
else:
|
|
385
|
+
out += f"\n verify: {vr.get('test', '?')}"
|
|
386
|
+
return out
|
|
291
387
|
|
|
292
388
|
return f"{_c(GREEN, 'OK')} Resolved {resolved}."
|
|
293
389
|
|
|
@@ -500,6 +596,118 @@ def _format_patch_meta(result: dict) -> str:
|
|
|
500
596
|
return f" {k}: {v}" if k else f"{_c(GREEN, 'OK')} Done."
|
|
501
597
|
|
|
502
598
|
|
|
599
|
+
def _format_patch_lock(result: dict) -> str:
|
|
600
|
+
"""Format patch lock/unlock result."""
|
|
601
|
+
if result.get("ok") is False:
|
|
602
|
+
return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
|
|
603
|
+
patch = result.get("patch", "")
|
|
604
|
+
owner = result.get("owner", "")
|
|
605
|
+
if result.get("was_locked") is False:
|
|
606
|
+
return f"{_c(GREEN, 'OK')} {patch} was not locked."
|
|
607
|
+
if "locked_at" in result:
|
|
608
|
+
reason = result.get("reason", "")
|
|
609
|
+
msg = f"{_c(GREEN, 'OK')} Locked {_c(BOLD, patch)} by {owner}"
|
|
610
|
+
if reason:
|
|
611
|
+
msg += f" ({reason})"
|
|
612
|
+
return msg
|
|
613
|
+
prev = result.get("previous_owner", "")
|
|
614
|
+
if prev and prev != owner:
|
|
615
|
+
return f"{_c(GREEN, 'OK')} Unlocked {_c(BOLD, patch)} (was locked by {prev})"
|
|
616
|
+
return f"{_c(GREEN, 'OK')} Unlocked {_c(BOLD, patch)}"
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _format_patch_check(result: dict) -> str:
|
|
620
|
+
"""Format patch check (obsolescence detection) result."""
|
|
621
|
+
if result.get("ok") is False:
|
|
622
|
+
return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
|
|
623
|
+
patches = result.get("patches", [])
|
|
624
|
+
if not patches:
|
|
625
|
+
return "No patches to check."
|
|
626
|
+
lines = []
|
|
627
|
+
for p in patches:
|
|
628
|
+
status = p.get("status", "unknown")
|
|
629
|
+
name = p.get("name", "?")
|
|
630
|
+
reason = p.get("reason", "")
|
|
631
|
+
if status == "obsolete":
|
|
632
|
+
lines.append(f" {_c(YELLOW, 'OBSOLETE')} {_c(BOLD, name)} — {reason}")
|
|
633
|
+
elif status == "active":
|
|
634
|
+
lines.append(f" {_c(GREEN, 'ACTIVE')} {_c(BOLD, name)} — {reason}")
|
|
635
|
+
else:
|
|
636
|
+
lines.append(f" {_c(DIM, 'UNKNOWN')} {name} — {reason}")
|
|
637
|
+
header = f"Checked {len(patches)} patch(es):"
|
|
638
|
+
return header + "\n" + "\n".join(lines)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _format_patch_upstream(result: dict) -> str:
|
|
642
|
+
"""Format patch upstream (PR-ready export) result."""
|
|
643
|
+
if result.get("ok") is False:
|
|
644
|
+
return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
|
|
645
|
+
name = result.get("patch", "")
|
|
646
|
+
desc = result.get("description", "")
|
|
647
|
+
files = result.get("files", [])
|
|
648
|
+
stats = result.get("stats", "")
|
|
649
|
+
diff = result.get("diff", "")
|
|
650
|
+
lines = [
|
|
651
|
+
f"{_c(BOLD, 'PR-ready export:')} {name}",
|
|
652
|
+
f" Description: {desc}",
|
|
653
|
+
f" Files: {', '.join(files)}",
|
|
654
|
+
]
|
|
655
|
+
if stats:
|
|
656
|
+
lines.append(f" Stats: {stats}")
|
|
657
|
+
lines.append("")
|
|
658
|
+
lines.append(diff)
|
|
659
|
+
return "\n".join(lines)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _format_patch_expire(result: dict) -> str:
|
|
663
|
+
"""Format patch expire result."""
|
|
664
|
+
if result.get("ok") is False:
|
|
665
|
+
return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
|
|
666
|
+
expired = result.get("expired", [])
|
|
667
|
+
expiring = result.get("expiring_soon", [])
|
|
668
|
+
if not expired and not expiring:
|
|
669
|
+
return f"{_c(GREEN, 'OK')} No expired or expiring patches."
|
|
670
|
+
lines = []
|
|
671
|
+
if expired:
|
|
672
|
+
lines.append(f"{_c(RED, 'Expired')} ({len(expired)}):")
|
|
673
|
+
for e in expired:
|
|
674
|
+
lines.append(f" {_c(RED, 'x')} {_c(BOLD, e['name'])} — expired {e['expires']} ({abs(e['days_left'])}d ago)")
|
|
675
|
+
if expiring:
|
|
676
|
+
lines.append(f"{_c(YELLOW, 'Expiring soon')} ({len(expiring)}):")
|
|
677
|
+
for e in expiring:
|
|
678
|
+
lines.append(f" {_c(YELLOW, '!')} {_c(BOLD, e['name'])} — expires {e['expires']} ({e['days_left']}d left)")
|
|
679
|
+
return "\n".join(lines)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _format_patch_stats(result: dict) -> str:
|
|
683
|
+
"""Format patch stats result."""
|
|
684
|
+
if result.get("ok") is False:
|
|
685
|
+
return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
|
|
686
|
+
patches = result.get("patches", [])
|
|
687
|
+
if not patches:
|
|
688
|
+
return "No patches."
|
|
689
|
+
lines = [f"{'#':>3} {'Name':<20} {'Age':>6} {'Size':>10} {'Syncs':>6} {'Status':<12} {'Owner':<15}"]
|
|
690
|
+
lines.append("─" * 80)
|
|
691
|
+
for i, p in enumerate(patches, 1):
|
|
692
|
+
age = f"{p['age_days']}d" if p.get("age_days", -1) >= 0 else "?"
|
|
693
|
+
size = f"+{p.get('insertions', 0)}/-{p.get('deletions', 0)}"
|
|
694
|
+
syncs = str(p.get("syncs_survived", 0))
|
|
695
|
+
status = p.get("status", "")
|
|
696
|
+
owner = p.get("owner", "") or p.get("locked_by", "") or ""
|
|
697
|
+
lines.append(f"{i:>3} {p['name']:<20} {age:>6} {size:>10} {syncs:>6} {status:<12} {owner:<15}")
|
|
698
|
+
return "\n".join(lines)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _format_report(result: dict) -> str:
|
|
702
|
+
"""Format fork health report."""
|
|
703
|
+
if result.get("ok") is False:
|
|
704
|
+
return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
|
|
705
|
+
report = result.get("report", "")
|
|
706
|
+
if report:
|
|
707
|
+
return report
|
|
708
|
+
return f"{_c(GREEN, 'OK')} Report generated."
|
|
709
|
+
|
|
710
|
+
|
|
503
711
|
def _format_undo(result: dict) -> str:
|
|
504
712
|
"""Format undo result."""
|
|
505
713
|
if result.get("ok") is False:
|
|
@@ -740,7 +948,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
740
948
|
sub.add_parser("diff", aliases=["d"], add_help=False)
|
|
741
949
|
|
|
742
950
|
# doctor
|
|
743
|
-
sub.add_parser("doctor", add_help=False)
|
|
951
|
+
p_doctor = sub.add_parser("doctor", add_help=False)
|
|
952
|
+
p_doctor.add_argument("--report", action="store_true")
|
|
953
|
+
|
|
954
|
+
# report
|
|
955
|
+
sub.add_parser("report", add_help=False)
|
|
744
956
|
|
|
745
957
|
# log
|
|
746
958
|
sub.add_parser("log", add_help=False)
|
|
@@ -755,6 +967,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
755
967
|
cr = sub.add_parser("conflict-resolve", add_help=False)
|
|
756
968
|
cr.add_argument("resolve_file", nargs="?", default="")
|
|
757
969
|
cr.add_argument("--content-stdin", action="store_true")
|
|
970
|
+
cr.add_argument("--verify", action="store_true",
|
|
971
|
+
help="Run test.command after the final rebase continues")
|
|
758
972
|
|
|
759
973
|
# session
|
|
760
974
|
p_session = sub.add_parser("session", add_help=False)
|
|
@@ -811,6 +1025,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
811
1025
|
pp_meta.add_argument("meta_key", nargs="?", default="")
|
|
812
1026
|
pp_meta.add_argument("meta_value", nargs="?", default="")
|
|
813
1027
|
|
|
1028
|
+
pp_lock = patch_sub.add_parser("lock", add_help=False)
|
|
1029
|
+
pp_lock.add_argument("target")
|
|
1030
|
+
pp_lock.add_argument("--reason", default="")
|
|
1031
|
+
|
|
1032
|
+
pp_unlock = patch_sub.add_parser("unlock", add_help=False)
|
|
1033
|
+
pp_unlock.add_argument("target")
|
|
1034
|
+
pp_unlock.add_argument("--force", action="store_true")
|
|
1035
|
+
|
|
1036
|
+
pp_check = patch_sub.add_parser("check", add_help=False)
|
|
1037
|
+
pp_check.add_argument("target", nargs="?", default="")
|
|
1038
|
+
|
|
1039
|
+
pp_upstream = patch_sub.add_parser("upstream", add_help=False)
|
|
1040
|
+
pp_upstream.add_argument("target")
|
|
1041
|
+
|
|
1042
|
+
patch_sub.add_parser("expire", add_help=False)
|
|
1043
|
+
patch_sub.add_parser("stats", add_help=False)
|
|
1044
|
+
|
|
814
1045
|
# workspace
|
|
815
1046
|
p_ws = sub.add_parser("workspace", aliases=["ws"], add_help=False)
|
|
816
1047
|
ws_sub = p_ws.add_subparsers(dest="ws_command")
|
|
@@ -853,6 +1084,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
853
1084
|
dp_drop.add_argument("dep_package")
|
|
854
1085
|
dp_drop.add_argument("dep_patch_name", nargs="?", default="")
|
|
855
1086
|
|
|
1087
|
+
# dep override subcommands
|
|
1088
|
+
dp_override = dep_sub.add_parser("override", add_help=False)
|
|
1089
|
+
override_sub = dp_override.add_subparsers(dest="override_command")
|
|
1090
|
+
override_sub.add_parser("list", add_help=False)
|
|
1091
|
+
override_sub.add_parser("check", add_help=False)
|
|
1092
|
+
ov_add = override_sub.add_parser("add", add_help=False)
|
|
1093
|
+
ov_add.add_argument("override_package")
|
|
1094
|
+
ov_add.add_argument("override_version")
|
|
1095
|
+
ov_add.add_argument("--reason", dest="override_reason", default="")
|
|
1096
|
+
ov_drop = override_sub.add_parser("drop", add_help=False)
|
|
1097
|
+
ov_drop.add_argument("override_package")
|
|
1098
|
+
|
|
1099
|
+
# dep fork subcommands
|
|
1100
|
+
dp_fork = dep_sub.add_parser("fork", add_help=False)
|
|
1101
|
+
fork_sub = dp_fork.add_subparsers(dest="fork_command")
|
|
1102
|
+
fork_sub.add_parser("list", add_help=False)
|
|
1103
|
+
fork_sub.add_parser("check", add_help=False)
|
|
1104
|
+
fk_sync = fork_sub.add_parser("sync", add_help=False)
|
|
1105
|
+
fk_sync.add_argument("fork_package")
|
|
1106
|
+
|
|
856
1107
|
# help
|
|
857
1108
|
sub.add_parser("help", add_help=False)
|
|
858
1109
|
|
|
@@ -884,6 +1135,31 @@ def dispatch(args: argparse.Namespace, json_mode: bool) -> Optional[dict]:
|
|
|
884
1135
|
return dm.sync()
|
|
885
1136
|
if dcmd == "drop":
|
|
886
1137
|
return dm.drop(args.dep_package, getattr(args, "dep_patch_name", ""))
|
|
1138
|
+
if dcmd == "override":
|
|
1139
|
+
ocmd = getattr(args, "override_command", "")
|
|
1140
|
+
if ocmd == "list":
|
|
1141
|
+
return dm.override_list()
|
|
1142
|
+
if ocmd == "check":
|
|
1143
|
+
return dm.override_check()
|
|
1144
|
+
if ocmd == "add":
|
|
1145
|
+
return dm.override_add(
|
|
1146
|
+
args.override_package, args.override_version,
|
|
1147
|
+
getattr(args, "override_reason", ""),
|
|
1148
|
+
)
|
|
1149
|
+
if ocmd == "drop":
|
|
1150
|
+
return dm.override_drop(args.override_package)
|
|
1151
|
+
return {"ok": False, "error": f"Unknown override subcommand: {ocmd}"}
|
|
1152
|
+
if dcmd == "fork":
|
|
1153
|
+
from bingo_core.dep_fork import ForkTracker
|
|
1154
|
+
ft = ForkTracker()
|
|
1155
|
+
fcmd = getattr(args, "fork_command", "")
|
|
1156
|
+
if fcmd == "list":
|
|
1157
|
+
return ft.fork_list()
|
|
1158
|
+
if fcmd == "check":
|
|
1159
|
+
return ft.fork_check()
|
|
1160
|
+
if fcmd == "sync":
|
|
1161
|
+
return ft.fork_sync(args.fork_package)
|
|
1162
|
+
return {"ok": False, "error": f"Unknown fork subcommand: {fcmd}"}
|
|
887
1163
|
return {"ok": False, "error": f"Unknown dep subcommand: {dcmd}"}
|
|
888
1164
|
|
|
889
1165
|
# setup doesn't need a Repo (works outside git repos)
|
|
@@ -919,7 +1195,10 @@ def dispatch(args: argparse.Namespace, json_mode: bool) -> Optional[dict]:
|
|
|
919
1195
|
return repo.diff()
|
|
920
1196
|
|
|
921
1197
|
if cmd == "doctor":
|
|
922
|
-
return repo.doctor()
|
|
1198
|
+
return repo.doctor(report=getattr(args, "report", False))
|
|
1199
|
+
|
|
1200
|
+
if cmd == "report":
|
|
1201
|
+
return repo.report()
|
|
923
1202
|
|
|
924
1203
|
if cmd == "log":
|
|
925
1204
|
return repo.history()
|
|
@@ -935,7 +1214,9 @@ def dispatch(args: argparse.Namespace, json_mode: bool) -> Optional[dict]:
|
|
|
935
1214
|
if args.content_stdin:
|
|
936
1215
|
import sys as _sys
|
|
937
1216
|
content = _sys.stdin.read()
|
|
938
|
-
return repo.conflict_resolve(
|
|
1217
|
+
return repo.conflict_resolve(
|
|
1218
|
+
args.resolve_file, content, verify=args.verify
|
|
1219
|
+
)
|
|
939
1220
|
|
|
940
1221
|
if cmd == "session":
|
|
941
1222
|
update = (args.session_action == "update")
|
|
@@ -1018,6 +1299,24 @@ def _dispatch_patch(args: argparse.Namespace, repo: Repo) -> dict:
|
|
|
1018
1299
|
value=meta_value,
|
|
1019
1300
|
)
|
|
1020
1301
|
|
|
1302
|
+
if pcmd == "lock":
|
|
1303
|
+
return repo.patch_lock(args.target, reason=getattr(args, "reason", ""))
|
|
1304
|
+
|
|
1305
|
+
if pcmd == "unlock":
|
|
1306
|
+
return repo.patch_unlock(args.target, force=getattr(args, "force", False))
|
|
1307
|
+
|
|
1308
|
+
if pcmd == "check":
|
|
1309
|
+
return repo.patch_check(getattr(args, "target", ""))
|
|
1310
|
+
|
|
1311
|
+
if pcmd == "upstream":
|
|
1312
|
+
return repo.patch_upstream(args.target)
|
|
1313
|
+
|
|
1314
|
+
if pcmd == "expire":
|
|
1315
|
+
return repo.patch_expire()
|
|
1316
|
+
|
|
1317
|
+
if pcmd == "stats":
|
|
1318
|
+
return repo.patch_stats()
|
|
1319
|
+
|
|
1021
1320
|
raise BingoError(f"Unknown patch subcommand: {pcmd}")
|
|
1022
1321
|
|
|
1023
1322
|
|
|
@@ -1075,6 +1374,7 @@ _FORMATTERS: Dict[str, Any] = {
|
|
|
1075
1374
|
"smart-sync": _format_smart_sync,
|
|
1076
1375
|
"setup": _format_setup,
|
|
1077
1376
|
"dep": _format_dep,
|
|
1377
|
+
"report": _format_report,
|
|
1078
1378
|
}
|
|
1079
1379
|
|
|
1080
1380
|
|
|
@@ -1099,6 +1399,16 @@ def _get_formatter(args: argparse.Namespace):
|
|
|
1099
1399
|
return _format_patch_import
|
|
1100
1400
|
if pcmd == "meta":
|
|
1101
1401
|
return _format_patch_meta
|
|
1402
|
+
if pcmd in ("lock", "unlock"):
|
|
1403
|
+
return _format_patch_lock
|
|
1404
|
+
if pcmd == "check":
|
|
1405
|
+
return _format_patch_check
|
|
1406
|
+
if pcmd == "upstream":
|
|
1407
|
+
return _format_patch_upstream
|
|
1408
|
+
if pcmd == "expire":
|
|
1409
|
+
return _format_patch_expire
|
|
1410
|
+
if pcmd == "stats":
|
|
1411
|
+
return _format_patch_stats
|
|
1102
1412
|
return _format_generic
|
|
1103
1413
|
|
|
1104
1414
|
# workspace subcommands
|
package/bingo_core/__init__.py
CHANGED
|
@@ -14,7 +14,7 @@ import re
|
|
|
14
14
|
|
|
15
15
|
# --- Constants ---
|
|
16
16
|
|
|
17
|
-
VERSION = "2.
|
|
17
|
+
VERSION = "2.2.0"
|
|
18
18
|
PATCH_PREFIX = "[bl]"
|
|
19
19
|
CONFIG_FILE = ".bingolight"
|
|
20
20
|
BINGO_DIR = ".bingo"
|
|
@@ -39,9 +39,12 @@ from bingo_core.exceptions import ( # noqa: E402
|
|
|
39
39
|
DirtyTreeError,
|
|
40
40
|
)
|
|
41
41
|
from bingo_core.models import PatchInfo, ConflictInfo # noqa: E402
|
|
42
|
+
from bingo_core.semantic import classify_conflict # noqa: E402
|
|
43
|
+
from bingo_core.decisions import DecisionMemory, detect_resolution_strategy # noqa: E402
|
|
42
44
|
from bingo_core.git import Git # noqa: E402
|
|
43
45
|
from bingo_core.config import Config # noqa: E402
|
|
44
46
|
from bingo_core.state import State # noqa: E402
|
|
47
|
+
from bingo_core.team import TeamState # noqa: E402
|
|
45
48
|
from bingo_core.repo import Repo # noqa: E402
|
|
46
49
|
|
|
47
50
|
__all__ = [
|
|
@@ -69,9 +72,14 @@ __all__ = [
|
|
|
69
72
|
# Data classes
|
|
70
73
|
"PatchInfo",
|
|
71
74
|
"ConflictInfo",
|
|
75
|
+
# Functions
|
|
76
|
+
"classify_conflict",
|
|
77
|
+
"detect_resolution_strategy",
|
|
72
78
|
# Classes
|
|
79
|
+
"DecisionMemory",
|
|
73
80
|
"Git",
|
|
74
81
|
"Config",
|
|
75
82
|
"State",
|
|
83
|
+
"TeamState",
|
|
76
84
|
"Repo",
|
|
77
85
|
]
|