bingo-light 2.1.3 → 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 +9 -5
- package/README.md +10 -1
- package/bingo-light +52 -0
- package/bingo_core/__init__.py +7 -1
- package/bingo_core/decisions.py +167 -0
- package/bingo_core/models.py +1 -0
- package/bingo_core/repo.py +236 -1
- package/bingo_core/semantic.py +85 -0
- package/mcp-server.py +8 -5
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -69,11 +69,15 @@ 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`
|
|
73
|
-
- **`patch_intent`** — patch name, subject, full commit message, original SHA, original diff, metadata
|
|
74
|
-
- **`verify`** — configured `test.command`
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
77
81
|
|
|
78
82
|
---
|
|
79
83
|
|
package/README.md
CHANGED
|
@@ -187,7 +187,16 @@ cd bingo-light && make install && bingo-light setup
|
|
|
187
187
|
}
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
`conflict-analyze` 在 rebase
|
|
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。
|
|
191
200
|
|
|
192
201
|
</details>
|
|
193
202
|
|
package/bingo-light
CHANGED
|
@@ -269,6 +269,58 @@ def _format_conflict_analyze(result: dict) -> str:
|
|
|
269
269
|
for h in verify.get("file_hints", []):
|
|
270
270
|
lines.append(f" [{h['kind']}] {h['command']}")
|
|
271
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
|
+
|
|
272
324
|
return "\n".join(lines)
|
|
273
325
|
|
|
274
326
|
|
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,6 +39,8 @@ 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
|
|
@@ -70,7 +72,11 @@ __all__ = [
|
|
|
70
72
|
# Data classes
|
|
71
73
|
"PatchInfo",
|
|
72
74
|
"ConflictInfo",
|
|
75
|
+
# Functions
|
|
76
|
+
"classify_conflict",
|
|
77
|
+
"detect_resolution_strategy",
|
|
73
78
|
# Classes
|
|
79
|
+
"DecisionMemory",
|
|
74
80
|
"Git",
|
|
75
81
|
"Config",
|
|
76
82
|
"State",
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bingo_core.decisions — per-patch conflict-resolution memory.
|
|
3
|
+
|
|
4
|
+
Complements git rerere (which keys by literal conflict text) with a
|
|
5
|
+
pattern-level memory: records how patch X was resolved against upstream
|
|
6
|
+
commit Y, keyed by (patch_name, file, semantic_class). When the same
|
|
7
|
+
patch conflicts again in a similar pattern, previous decisions are
|
|
8
|
+
surfaced to the AI during conflict-analyze so it can consider the
|
|
9
|
+
prior choice.
|
|
10
|
+
|
|
11
|
+
Storage: .bingo/decisions/<patch-name>.json, one file per patch.
|
|
12
|
+
This avoids hot contention and keeps each patch's history isolated.
|
|
13
|
+
|
|
14
|
+
Python 3.8+ stdlib only. No external dependencies.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from typing import List, Optional
|
|
24
|
+
|
|
25
|
+
# Patch name constraint mirrors PATCH_NAME_RE to keep filenames safe.
|
|
26
|
+
_SAFE_PATCH_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
27
|
+
MAX_DECISIONS_PER_PATCH = 50
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DecisionMemory:
|
|
31
|
+
"""Per-patch decision log stored under .bingo/decisions/."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, repo_path: str):
|
|
34
|
+
self.repo_path = repo_path
|
|
35
|
+
self.dir = os.path.join(repo_path, ".bingo", "decisions")
|
|
36
|
+
|
|
37
|
+
def _path_for(self, patch_name: str) -> Optional[str]:
|
|
38
|
+
if not patch_name or not _SAFE_PATCH_NAME.match(patch_name):
|
|
39
|
+
return None
|
|
40
|
+
return os.path.join(self.dir, f"{patch_name}.json")
|
|
41
|
+
|
|
42
|
+
def _load_all(self, patch_name: str) -> List[dict]:
|
|
43
|
+
path = self._path_for(patch_name)
|
|
44
|
+
if not path or not os.path.isfile(path):
|
|
45
|
+
return []
|
|
46
|
+
try:
|
|
47
|
+
with open(path) as f:
|
|
48
|
+
data = json.load(f)
|
|
49
|
+
except (IOError, OSError, json.JSONDecodeError):
|
|
50
|
+
return []
|
|
51
|
+
decisions = data.get("decisions", [])
|
|
52
|
+
return decisions if isinstance(decisions, list) else []
|
|
53
|
+
|
|
54
|
+
def _save_all(self, patch_name: str, decisions: List[dict]) -> None:
|
|
55
|
+
path = self._path_for(patch_name)
|
|
56
|
+
if not path:
|
|
57
|
+
return
|
|
58
|
+
os.makedirs(self.dir, exist_ok=True)
|
|
59
|
+
tmp = path + ".tmp"
|
|
60
|
+
try:
|
|
61
|
+
with open(tmp, "w") as f:
|
|
62
|
+
json.dump(
|
|
63
|
+
{"patch": patch_name, "decisions": decisions},
|
|
64
|
+
f, indent=2,
|
|
65
|
+
)
|
|
66
|
+
os.replace(tmp, path)
|
|
67
|
+
except (IOError, OSError):
|
|
68
|
+
try:
|
|
69
|
+
os.unlink(tmp)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def record(
|
|
74
|
+
self,
|
|
75
|
+
patch_name: str,
|
|
76
|
+
file: str,
|
|
77
|
+
semantic_class: str,
|
|
78
|
+
resolution_strategy: str,
|
|
79
|
+
upstream_sha: Optional[str] = None,
|
|
80
|
+
upstream_subject: Optional[str] = None,
|
|
81
|
+
notes: str = "",
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Append one decision for a patch.
|
|
84
|
+
|
|
85
|
+
Silently no-ops if patch_name is empty or invalid. The newest
|
|
86
|
+
MAX_DECISIONS_PER_PATCH entries are retained; older entries are
|
|
87
|
+
dropped FIFO to keep files bounded.
|
|
88
|
+
"""
|
|
89
|
+
if not patch_name or not _SAFE_PATCH_NAME.match(patch_name):
|
|
90
|
+
return
|
|
91
|
+
entry = {
|
|
92
|
+
"timestamp": datetime.now(timezone.utc).strftime(
|
|
93
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
94
|
+
),
|
|
95
|
+
"file": file,
|
|
96
|
+
"semantic_class": semantic_class,
|
|
97
|
+
"resolution_strategy": resolution_strategy,
|
|
98
|
+
"upstream_sha": upstream_sha,
|
|
99
|
+
"upstream_subject": upstream_subject,
|
|
100
|
+
"notes": notes,
|
|
101
|
+
}
|
|
102
|
+
decisions = self._load_all(patch_name)
|
|
103
|
+
decisions.append(entry)
|
|
104
|
+
if len(decisions) > MAX_DECISIONS_PER_PATCH:
|
|
105
|
+
decisions = decisions[-MAX_DECISIONS_PER_PATCH:]
|
|
106
|
+
self._save_all(patch_name, decisions)
|
|
107
|
+
|
|
108
|
+
def lookup(
|
|
109
|
+
self,
|
|
110
|
+
patch_name: str,
|
|
111
|
+
file: Optional[str] = None,
|
|
112
|
+
semantic_class: Optional[str] = None,
|
|
113
|
+
limit: int = 5,
|
|
114
|
+
) -> List[dict]:
|
|
115
|
+
"""Return up to `limit` previous decisions, most-recent first,
|
|
116
|
+
ranked by relevance to the given file/semantic_class.
|
|
117
|
+
|
|
118
|
+
Ranking: +2 if file matches, +1 if semantic_class matches.
|
|
119
|
+
Ties broken by recency.
|
|
120
|
+
"""
|
|
121
|
+
decisions = self._load_all(patch_name)
|
|
122
|
+
if not decisions:
|
|
123
|
+
return []
|
|
124
|
+
|
|
125
|
+
def score(d: dict) -> tuple:
|
|
126
|
+
relevance = 0
|
|
127
|
+
if file and d.get("file") == file:
|
|
128
|
+
relevance += 2
|
|
129
|
+
if semantic_class and d.get("semantic_class") == semantic_class:
|
|
130
|
+
relevance += 1
|
|
131
|
+
return (relevance, d.get("timestamp", ""))
|
|
132
|
+
|
|
133
|
+
ranked = sorted(decisions, key=score, reverse=True)
|
|
134
|
+
result = []
|
|
135
|
+
for d in ranked[:limit]:
|
|
136
|
+
# Add a human-readable relevance tag
|
|
137
|
+
tag = []
|
|
138
|
+
if file and d.get("file") == file:
|
|
139
|
+
tag.append("same_file")
|
|
140
|
+
if semantic_class and d.get("semantic_class") == semantic_class:
|
|
141
|
+
tag.append("same_class")
|
|
142
|
+
entry = dict(d)
|
|
143
|
+
entry["relevance"] = "+".join(tag) if tag else "recent"
|
|
144
|
+
result.append(entry)
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def detect_resolution_strategy(
|
|
149
|
+
resolved_content: str, ours: str, theirs: str
|
|
150
|
+
) -> str:
|
|
151
|
+
"""Classify how a resolution was produced by comparing bytes.
|
|
152
|
+
|
|
153
|
+
Returns "keep_ours" / "keep_theirs" / "manual". Exact-match required
|
|
154
|
+
because partial merges still count as manual. Empty `resolved_content`
|
|
155
|
+
returns "manual" (caller didn't tell us what was written).
|
|
156
|
+
"""
|
|
157
|
+
if not resolved_content:
|
|
158
|
+
return "manual"
|
|
159
|
+
# Strip trailing whitespace on both sides to be forgiving of newline diffs.
|
|
160
|
+
r = resolved_content.rstrip()
|
|
161
|
+
o = (ours or "").rstrip()
|
|
162
|
+
t = (theirs or "").rstrip()
|
|
163
|
+
if r == o:
|
|
164
|
+
return "keep_ours"
|
|
165
|
+
if r == t:
|
|
166
|
+
return "keep_theirs"
|
|
167
|
+
return "manual"
|
package/bingo_core/models.py
CHANGED
package/bingo_core/repo.py
CHANGED
|
@@ -31,6 +31,8 @@ from bingo_core.exceptions import (
|
|
|
31
31
|
DirtyTreeError,
|
|
32
32
|
)
|
|
33
33
|
from bingo_core.models import ConflictInfo
|
|
34
|
+
from bingo_core.semantic import classify_conflict
|
|
35
|
+
from bingo_core.decisions import DecisionMemory, detect_resolution_strategy
|
|
34
36
|
from bingo_core.git import Git
|
|
35
37
|
from bingo_core.config import Config
|
|
36
38
|
from bingo_core.state import State
|
|
@@ -46,6 +48,7 @@ class Repo:
|
|
|
46
48
|
self.config = Config(self.path)
|
|
47
49
|
self.state = State(self.path)
|
|
48
50
|
self.team = TeamState(self.path, git=self.git)
|
|
51
|
+
self.decisions = DecisionMemory(self.path)
|
|
49
52
|
|
|
50
53
|
# -- Internal helpers --
|
|
51
54
|
|
|
@@ -359,6 +362,167 @@ class Repo:
|
|
|
359
362
|
".sh": ("bash -n {path}", "syntax"),
|
|
360
363
|
}
|
|
361
364
|
|
|
365
|
+
_PR_NUMBER_RE = re.compile(r"(?:#|pull request #)(\d+)")
|
|
366
|
+
|
|
367
|
+
def _build_patch_dependencies(self, current_name: str) -> Optional[dict]:
|
|
368
|
+
"""Find later patches in the stack that touch the same files as current.
|
|
369
|
+
|
|
370
|
+
Useful for detecting cascade risk: if we're resolving a conflict in
|
|
371
|
+
patch A, and patches B/C/D build on A's changes to the same files,
|
|
372
|
+
the AI should consider whether its merge choice will cascade.
|
|
373
|
+
|
|
374
|
+
Returns {current_patch, dependents: [{name, subject, position,
|
|
375
|
+
overlapping_files}]} or None if no stack info available.
|
|
376
|
+
"""
|
|
377
|
+
if not current_name:
|
|
378
|
+
return None
|
|
379
|
+
try:
|
|
380
|
+
c = self._load()
|
|
381
|
+
base = self._patches_base(c)
|
|
382
|
+
if not base:
|
|
383
|
+
return None
|
|
384
|
+
try:
|
|
385
|
+
log_output = self.git.run(
|
|
386
|
+
"rev-list", "--reverse",
|
|
387
|
+
f"{base}..{c['patches_branch']}"
|
|
388
|
+
)
|
|
389
|
+
except GitError:
|
|
390
|
+
return None
|
|
391
|
+
shas = log_output.splitlines()
|
|
392
|
+
if not shas:
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
# Collect (index, name, subject, sha, files) for each patch.
|
|
396
|
+
patches_info = []
|
|
397
|
+
for idx, sha in enumerate(shas, start=1):
|
|
398
|
+
try:
|
|
399
|
+
subj = self.git.run("log", "-1", "--format=%s", sha).strip()
|
|
400
|
+
except GitError:
|
|
401
|
+
continue
|
|
402
|
+
m = self._PATCH_SUBJECT_RE.match(subj)
|
|
403
|
+
if not m:
|
|
404
|
+
continue
|
|
405
|
+
try:
|
|
406
|
+
files_out = self.git.run(
|
|
407
|
+
"show", "--format=", "--name-only", sha
|
|
408
|
+
)
|
|
409
|
+
except GitError:
|
|
410
|
+
files_out = ""
|
|
411
|
+
files = [ln for ln in files_out.splitlines() if ln]
|
|
412
|
+
patches_info.append({
|
|
413
|
+
"index": idx,
|
|
414
|
+
"name": m.group(1),
|
|
415
|
+
"subject": m.group(2).strip(),
|
|
416
|
+
"files": set(files),
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
# Find current patch and its files.
|
|
420
|
+
cur = next(
|
|
421
|
+
(p for p in patches_info if p["name"] == current_name), None
|
|
422
|
+
)
|
|
423
|
+
if cur is None:
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
# Later patches that overlap.
|
|
427
|
+
dependents = []
|
|
428
|
+
for p in patches_info:
|
|
429
|
+
if p["index"] <= cur["index"]:
|
|
430
|
+
continue
|
|
431
|
+
overlap = cur["files"] & p["files"]
|
|
432
|
+
if overlap:
|
|
433
|
+
dependents.append({
|
|
434
|
+
"name": p["name"],
|
|
435
|
+
"subject": p["subject"],
|
|
436
|
+
"position": p["index"],
|
|
437
|
+
"overlapping_files": sorted(overlap),
|
|
438
|
+
})
|
|
439
|
+
return {
|
|
440
|
+
"current_patch": current_name,
|
|
441
|
+
"dependents": dependents,
|
|
442
|
+
}
|
|
443
|
+
except Exception:
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
def _build_upstream_context(self, conflicted_files: List[str]) -> Optional[dict]:
|
|
447
|
+
"""Find upstream commits that modified the conflicting files.
|
|
448
|
+
|
|
449
|
+
Uses .bingo/.undo-tracking (pre-sync upstream position) as the
|
|
450
|
+
baseline and the current tracking branch as the target. For each
|
|
451
|
+
conflicted file, lists upstream commits between those two points.
|
|
452
|
+
|
|
453
|
+
Returns a dict {range, total_commits, commits_touching_conflicts}
|
|
454
|
+
or None if the comparison range cannot be established.
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
_head, old_tracking = self.state.load_undo()
|
|
458
|
+
except Exception:
|
|
459
|
+
return None
|
|
460
|
+
if not old_tracking:
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
c = self._load()
|
|
465
|
+
except Exception:
|
|
466
|
+
return None
|
|
467
|
+
# During a conflict, _sync_locked rolls back the tracking branch,
|
|
468
|
+
# so we use the remote-tracking ref (upstream/<branch>) which
|
|
469
|
+
# reflects the fetched target position.
|
|
470
|
+
new_tracking = (
|
|
471
|
+
self.git.rev_parse(f"upstream/{c['upstream_branch']}")
|
|
472
|
+
or self.git.rev_parse(c["tracking_branch"])
|
|
473
|
+
)
|
|
474
|
+
if not new_tracking or new_tracking == old_tracking:
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
commit_map: dict = {}
|
|
478
|
+
FMT = "%H%x1f%h%x1f%an%x1f%at%x1f%s"
|
|
479
|
+
for f in conflicted_files:
|
|
480
|
+
try:
|
|
481
|
+
out = self.git.run(
|
|
482
|
+
"log",
|
|
483
|
+
f"--format={FMT}",
|
|
484
|
+
f"{old_tracking}..{new_tracking}",
|
|
485
|
+
"--",
|
|
486
|
+
f,
|
|
487
|
+
)
|
|
488
|
+
except GitError:
|
|
489
|
+
continue
|
|
490
|
+
for line in out.splitlines():
|
|
491
|
+
parts = line.split("\x1f")
|
|
492
|
+
if len(parts) != 5:
|
|
493
|
+
continue
|
|
494
|
+
sha, short, author, ts, subject = parts
|
|
495
|
+
entry = commit_map.setdefault(sha, {
|
|
496
|
+
"sha": sha,
|
|
497
|
+
"short_sha": short,
|
|
498
|
+
"author": author,
|
|
499
|
+
"timestamp": int(ts) if ts.isdigit() else 0,
|
|
500
|
+
"subject": subject,
|
|
501
|
+
"files": [],
|
|
502
|
+
"pr": None,
|
|
503
|
+
})
|
|
504
|
+
if f not in entry["files"]:
|
|
505
|
+
entry["files"].append(f)
|
|
506
|
+
if entry["pr"] is None:
|
|
507
|
+
m = self._PR_NUMBER_RE.search(subject)
|
|
508
|
+
if m:
|
|
509
|
+
entry["pr"] = m.group(1)
|
|
510
|
+
|
|
511
|
+
total = 0
|
|
512
|
+
try:
|
|
513
|
+
total = self.git.rev_list_count(f"{old_tracking}..{new_tracking}")
|
|
514
|
+
except Exception:
|
|
515
|
+
pass
|
|
516
|
+
|
|
517
|
+
commits = sorted(
|
|
518
|
+
commit_map.values(), key=lambda x: x["timestamp"], reverse=True
|
|
519
|
+
)
|
|
520
|
+
return {
|
|
521
|
+
"range": f"{old_tracking[:7]}..{new_tracking[:7]}",
|
|
522
|
+
"total_commits": total,
|
|
523
|
+
"commits_touching_conflicts": commits,
|
|
524
|
+
}
|
|
525
|
+
|
|
362
526
|
def _verify_hints_for(self, files: List[str]) -> List[dict]:
|
|
363
527
|
"""Generate per-file verification commands by extension.
|
|
364
528
|
|
|
@@ -525,6 +689,7 @@ class Repo:
|
|
|
525
689
|
theirs=theirs,
|
|
526
690
|
conflict_count=conflict_count,
|
|
527
691
|
merge_hint=merge_hint,
|
|
692
|
+
semantic_class=classify_conflict(ours, theirs, filepath),
|
|
528
693
|
)
|
|
529
694
|
|
|
530
695
|
def _record_sync(self, c: dict, behind: int, saved_tracking: str) -> None:
|
|
@@ -1138,8 +1303,12 @@ class Repo:
|
|
|
1138
1303
|
"test_command": self.config.get("test.command") or None,
|
|
1139
1304
|
"file_hints": self._verify_hints_for(conflicted),
|
|
1140
1305
|
}
|
|
1306
|
+
upstream_context = self._build_upstream_context(conflicted)
|
|
1307
|
+
patch_dependencies = self._build_patch_dependencies(
|
|
1308
|
+
patch_intent.get("name", "") if patch_intent else ""
|
|
1309
|
+
)
|
|
1141
1310
|
|
|
1142
|
-
|
|
1311
|
+
result = {
|
|
1143
1312
|
"ok": True,
|
|
1144
1313
|
"in_rebase": True,
|
|
1145
1314
|
"current_patch": current_patch,
|
|
@@ -1155,6 +1324,34 @@ class Repo:
|
|
|
1155
1324
|
"6. To abort instead: git rebase --abort",
|
|
1156
1325
|
],
|
|
1157
1326
|
}
|
|
1327
|
+
if upstream_context is not None:
|
|
1328
|
+
result["upstream_context"] = upstream_context
|
|
1329
|
+
if patch_dependencies is not None:
|
|
1330
|
+
result["patch_dependencies"] = patch_dependencies
|
|
1331
|
+
|
|
1332
|
+
# Decision memory: look up previous resolutions for this patch.
|
|
1333
|
+
patch_name = patch_intent.get("name", "") if patch_intent else ""
|
|
1334
|
+
if patch_name:
|
|
1335
|
+
memory_entries = []
|
|
1336
|
+
for conflict in conflicts:
|
|
1337
|
+
prior = self.decisions.lookup(
|
|
1338
|
+
patch_name,
|
|
1339
|
+
file=conflict.file,
|
|
1340
|
+
semantic_class=conflict.semantic_class,
|
|
1341
|
+
limit=3,
|
|
1342
|
+
)
|
|
1343
|
+
if prior:
|
|
1344
|
+
memory_entries.append({
|
|
1345
|
+
"file": conflict.file,
|
|
1346
|
+
"semantic_class": conflict.semantic_class,
|
|
1347
|
+
"previous_decisions": prior,
|
|
1348
|
+
})
|
|
1349
|
+
if memory_entries:
|
|
1350
|
+
result["decision_memory"] = {
|
|
1351
|
+
"patch": patch_name,
|
|
1352
|
+
"entries": memory_entries,
|
|
1353
|
+
}
|
|
1354
|
+
return result
|
|
1158
1355
|
|
|
1159
1356
|
def conflict_resolve(
|
|
1160
1357
|
self, file_path: str, content: str = "", verify: bool = False
|
|
@@ -1197,6 +1394,9 @@ class Repo:
|
|
|
1197
1394
|
f"Unmerged files: {', '.join(unmerged) if unmerged else '(none)'}"
|
|
1198
1395
|
)
|
|
1199
1396
|
|
|
1397
|
+
# Capture pre-resolve conflict snapshot (for decision memory).
|
|
1398
|
+
pre_conflict = self._extract_conflict(rel_path)
|
|
1399
|
+
|
|
1200
1400
|
# Write content if provided
|
|
1201
1401
|
if content:
|
|
1202
1402
|
full_path = str(resolved)
|
|
@@ -1210,6 +1410,41 @@ class Repo:
|
|
|
1210
1410
|
if not self.git.run_ok("add", rel_path):
|
|
1211
1411
|
raise BingoError(f"Failed to stage file: {rel_path}")
|
|
1212
1412
|
|
|
1413
|
+
# Record decision memory (best-effort; silent on failure).
|
|
1414
|
+
try:
|
|
1415
|
+
intent = self._build_patch_intent()
|
|
1416
|
+
patch_name = intent.get("name", "") if intent else ""
|
|
1417
|
+
if patch_name:
|
|
1418
|
+
resolved_content = content
|
|
1419
|
+
if not resolved_content:
|
|
1420
|
+
try:
|
|
1421
|
+
with open(str(resolved)) as f:
|
|
1422
|
+
resolved_content = f.read()
|
|
1423
|
+
except (IOError, OSError):
|
|
1424
|
+
resolved_content = ""
|
|
1425
|
+
strategy = detect_resolution_strategy(
|
|
1426
|
+
resolved_content, pre_conflict.ours, pre_conflict.theirs
|
|
1427
|
+
)
|
|
1428
|
+
# Pick the first upstream commit touching this file as the
|
|
1429
|
+
# "triggering" upstream change (best-effort context).
|
|
1430
|
+
uc = self._build_upstream_context([rel_path])
|
|
1431
|
+
upstream_sha = None
|
|
1432
|
+
upstream_subject = None
|
|
1433
|
+
if uc and uc.get("commits_touching_conflicts"):
|
|
1434
|
+
top = uc["commits_touching_conflicts"][0]
|
|
1435
|
+
upstream_sha = top.get("sha")
|
|
1436
|
+
upstream_subject = top.get("subject")
|
|
1437
|
+
self.decisions.record(
|
|
1438
|
+
patch_name,
|
|
1439
|
+
file=rel_path,
|
|
1440
|
+
semantic_class=pre_conflict.semantic_class,
|
|
1441
|
+
resolution_strategy=strategy,
|
|
1442
|
+
upstream_sha=upstream_sha,
|
|
1443
|
+
upstream_subject=upstream_subject,
|
|
1444
|
+
)
|
|
1445
|
+
except Exception:
|
|
1446
|
+
pass # memory is best-effort; never block rebase on it
|
|
1447
|
+
|
|
1213
1448
|
# Check remaining unmerged files
|
|
1214
1449
|
remaining = self.git.ls_files_unmerged()
|
|
1215
1450
|
if remaining:
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bingo_core.semantic — semantic classification of conflict regions.
|
|
3
|
+
|
|
4
|
+
Given ours/theirs text for a single conflict region, return one of:
|
|
5
|
+
"whitespace" — regions differ only in whitespace
|
|
6
|
+
"import_reorder" — both regions are only import statements,
|
|
7
|
+
same set of imports, just reordered
|
|
8
|
+
"signature_change" — function/method signature changed (params
|
|
9
|
+
added, removed, or renamed) but name same
|
|
10
|
+
"logic" — default; real logic change requiring human
|
|
11
|
+
or AI reasoning
|
|
12
|
+
|
|
13
|
+
The classifier is intentionally conservative: when unsure, return
|
|
14
|
+
"logic" so callers treat it as a real conflict.
|
|
15
|
+
|
|
16
|
+
Python 3.8+ stdlib only.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
# Matches Python "import X" or "from X import Y" lines.
|
|
24
|
+
_IMPORT_RE_PY = re.compile(r"^\s*(?:import|from)\s+\S")
|
|
25
|
+
# Matches JS/TS imports and CommonJS require.
|
|
26
|
+
_IMPORT_RE_JS = re.compile(r"^\s*(?:import\s|const\s+\w+\s*=\s*require\()")
|
|
27
|
+
|
|
28
|
+
# Function signature captures: (name, params) for Python def and JS function.
|
|
29
|
+
_FN_SIG_PY = re.compile(r"^\s*(?:async\s+)?def\s+(\w+)\s*\(([^)]*)\)")
|
|
30
|
+
_FN_SIG_JS = re.compile(r"^\s*(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def classify_conflict(ours: str, theirs: str, file: str = "") -> str:
|
|
34
|
+
"""Classify a conflict region as whitespace / import_reorder /
|
|
35
|
+
signature_change / logic.
|
|
36
|
+
|
|
37
|
+
`file` is accepted for future extension (language-specific rules)
|
|
38
|
+
but not currently used.
|
|
39
|
+
"""
|
|
40
|
+
if _is_whitespace_only(ours, theirs):
|
|
41
|
+
return "whitespace"
|
|
42
|
+
if _is_import_reorder(ours, theirs):
|
|
43
|
+
return "import_reorder"
|
|
44
|
+
if _is_signature_change(ours, theirs):
|
|
45
|
+
return "signature_change"
|
|
46
|
+
return "logic"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_whitespace_only(a: str, b: str) -> bool:
|
|
50
|
+
"""True if the only difference is whitespace (tabs, spaces, newlines).
|
|
51
|
+
|
|
52
|
+
All whitespace is removed for comparison — matching git's
|
|
53
|
+
`diff --ignore-all-space` semantics.
|
|
54
|
+
"""
|
|
55
|
+
na = "".join(a.split())
|
|
56
|
+
nb = "".join(b.split())
|
|
57
|
+
return na == nb and na != ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_import_reorder(a: str, b: str) -> bool:
|
|
61
|
+
"""True if both sides contain only import statements, with the same
|
|
62
|
+
set of imports (just reordered)."""
|
|
63
|
+
a_lines = [ln for ln in a.splitlines() if ln.strip()]
|
|
64
|
+
b_lines = [ln for ln in b.splitlines() if ln.strip()]
|
|
65
|
+
if not a_lines or not b_lines:
|
|
66
|
+
return False
|
|
67
|
+
for line in a_lines + b_lines:
|
|
68
|
+
if not (_IMPORT_RE_PY.match(line) or _IMPORT_RE_JS.match(line)):
|
|
69
|
+
return False
|
|
70
|
+
# Compare as sorted sets: order-insensitive match.
|
|
71
|
+
return sorted(a_lines) == sorted(b_lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_signature_change(a: str, b: str) -> bool:
|
|
75
|
+
"""True if both sides contain a function definition for the same
|
|
76
|
+
name but with different parameter lists."""
|
|
77
|
+
for pattern in (_FN_SIG_PY, _FN_SIG_JS):
|
|
78
|
+
a_match = pattern.search(a)
|
|
79
|
+
b_match = pattern.search(b)
|
|
80
|
+
if a_match and b_match:
|
|
81
|
+
same_name = a_match.group(1) == b_match.group(1)
|
|
82
|
+
different_params = a_match.group(2).strip() != b_match.group(2).strip()
|
|
83
|
+
if same_name and different_params:
|
|
84
|
+
return True
|
|
85
|
+
return False
|
package/mcp-server.py
CHANGED
|
@@ -288,10 +288,13 @@ TOOLS = [
|
|
|
288
288
|
"name": "bingo_conflict_analyze",
|
|
289
289
|
"description": (
|
|
290
290
|
"Analyze current rebase conflicts. Returns structured info about each conflicted file "
|
|
291
|
-
"(ours/theirs, conflict count, hints
|
|
292
|
-
"
|
|
293
|
-
"(test_command + per-file
|
|
294
|
-
"
|
|
291
|
+
"(ours/theirs, conflict count, hints, semantic_class: whitespace/import_reorder/"
|
|
292
|
+
"signature_change/logic) plus: patch_intent (name, subject, full commit message, "
|
|
293
|
+
"original_sha, original_diff, meta, stack_position), verify (test_command + per-file "
|
|
294
|
+
"syntax/parse commands), upstream_context (upstream commits touching conflicts with "
|
|
295
|
+
"author/PR), patch_dependencies (later patches touching same files — cascade risk), "
|
|
296
|
+
"and decision_memory (prior resolutions for this patch from .bingo/decisions/). "
|
|
297
|
+
"Call this when bingo_sync reports a conflict."
|
|
295
298
|
),
|
|
296
299
|
"inputSchema": {
|
|
297
300
|
"type": "object",
|
|
@@ -1094,7 +1097,7 @@ def main():
|
|
|
1094
1097
|
"capabilities": {"tools": {}},
|
|
1095
1098
|
"serverInfo": {
|
|
1096
1099
|
"name": "bingo-light",
|
|
1097
|
-
"version": "2.
|
|
1100
|
+
"version": "2.2.0",
|
|
1098
1101
|
},
|
|
1099
1102
|
}))
|
|
1100
1103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bingo-light",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "AI-native fork maintenance — manage customizations as a clean patch stack on top of upstream",
|
|
5
5
|
"keywords": ["git", "fork", "patch", "mcp", "ai", "maintenance", "upstream", "rebase"],
|
|
6
6
|
"homepage": "https://github.com/DanOps-1/bingo-light",
|