Skip to content

Commit df8b9d1

Browse files
committed
Auto-resolve cherry-pick conflicts via AI chat in the pick branch window
Not working well yet
1 parent b998642 commit df8b9d1

File tree

8 files changed

+538
-43
lines changed

8 files changed

+538
-43
lines changed

qgitc/aichatwidget.py

Lines changed: 127 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# -*- coding: utf-8 -*-
22

33
import json
4+
import os
45
import typing
56
from typing import Any, Dict, List, Optional, Tuple, Union
67

@@ -40,6 +41,8 @@
4041
AGENT_SYS_PROMPT,
4142
CODE_REVIEW_PROMPT,
4243
CODE_REVIEW_SYS_PROMPT,
44+
RESOLVE_PROMPT,
45+
RESOLVE_SYS_PROMPT,
4346
)
4447
from qgitc.preferences import Preferences
4548
from qgitc.submoduleexecutor import SubmoduleExecutor
@@ -115,7 +118,11 @@ def __init__(self, parent=None, embedded=False):
115118
# Code review diff collection (staged/local changes)
116119
self._codeReviewExecutor: Optional[SubmoduleExecutor] = None
117120
self._codeReviewDiffs: List[str] = []
118-
self._codeReviewScene: str = None
121+
self._extraContext: str = None
122+
123+
# Sync helper flows can optionally auto-run a tightly-scoped set of
124+
# WRITE tools without user confirmation.
125+
self._allowWriteTools: bool = False
119126

120127
self._isInitialized = False
121128
QTimer.singleShot(100, self._onDelayInit)
@@ -136,10 +143,10 @@ def event(self, event: QEvent):
136143
return True
137144
if event.type() == CodeReviewSceneEvent.Type:
138145
if event.scene_line:
139-
if not self._codeReviewScene:
140-
self._codeReviewScene = event.scene_line
146+
if not self._extraContext:
147+
self._extraContext = event.scene_line
141148
else:
142-
self._codeReviewScene += "\n" + event.scene_line
149+
self._extraContext += "\n" + event.scene_line
143150
return True
144151
return super().event(event)
145152

@@ -281,7 +288,7 @@ def queryClose(self):
281288
self._codeReviewExecutor.cancel()
282289
self._codeReviewExecutor = None
283290
self._codeReviewDiffs.clear()
284-
self._codeReviewScene = None
291+
self._extraContext = None
285292

286293
def sizeHint(self):
287294
if self._embedded:
@@ -412,7 +419,7 @@ def _doRequest(self, prompt: str, chatMode: AiChatMode, sysPrompt: str = None, c
412419
titleSeed = (params.sys_prompt + "\n" +
413420
prompt) if params.sys_prompt else prompt
414421

415-
codeReviewContext: Optional[str] = None
422+
extraContext = self._extraContext
416423

417424
if chatMode == AiChatMode.Agent:
418425
params.tools = AgentToolRegistry.openai_tools()
@@ -430,9 +437,9 @@ def _doRequest(self, prompt: str, chatMode: AiChatMode, sysPrompt: str = None, c
430437
params.tool_choice = "auto"
431438
params.sys_prompt = sysPrompt or CODE_REVIEW_SYS_PROMPT
432439

433-
scene = (self._codeReviewScene or "").strip(
440+
scene = (self._extraContext or "").strip(
434441
) or "(no scene metadata)"
435-
codeReviewContext = "CODE REVIEW SCENE (metadata)\n" + scene
442+
extraContext = "CODE REVIEW SCENE (metadata)\n" + scene
436443
params.prompt = CODE_REVIEW_PROMPT.format(
437444
diff=params.prompt,
438445
language=ApplicationBase.instance().uiLanguage())
@@ -443,12 +450,12 @@ def _doRequest(self, prompt: str, chatMode: AiChatMode, sysPrompt: str = None, c
443450
contextText = provider.buildContextText(
444451
selectedIds) if provider is not None else ""
445452

446-
if chatMode == AiChatMode.CodeReview and codeReviewContext:
453+
if extraContext:
447454
merged = (contextText or "").strip()
448455
if merged:
449-
merged += "\n\n" + codeReviewContext
456+
merged += "\n\n" + extraContext
450457
else:
451-
merged = codeReviewContext
458+
merged = extraContext
452459
contextText = merged
453460

454461
if contextText:
@@ -531,7 +538,13 @@ def _doMessageReady(self, model: AiModelBase, response: AiResponse, collapsed=Fa
531538
tool = AgentToolRegistry.tool_by_name(
532539
toolName) if toolName else None
533540

534-
if toolName and tool and tool.tool_type == ToolType.READ_ONLY:
541+
# Auto-run only READ_ONLY tools by default. For certain sync helper
542+
# flows we optionally auto-run *WRITE* tools, but never DANGEROUS.
543+
if toolName and tool and (
544+
tool.tool_type == ToolType.READ_ONLY or (
545+
self._allowWriteTools and tool.tool_type == ToolType.WRITE
546+
)
547+
):
535548
if autoGroupId is None:
536549
autoGroupId = self._nextAutoGroupId
537550
self._nextAutoGroupId += 1
@@ -935,7 +948,7 @@ def _appendRepoFileSummary(repoLabel: str, sha1: str, repoDiff: str):
935948
curHistory = self._historyPanel.currentHistory()
936949
if not curHistory or curHistory.messages:
937950
self._createNewConversation()
938-
self._codeReviewScene = scene
951+
self._extraContext = scene
939952
self._doRequest(diff, AiChatMode.CodeReview)
940953

941954
def _makeFileList(self, files: List[str]) -> str:
@@ -957,7 +970,7 @@ def codeReviewForStagedFiles(self, submodules):
957970
"""Start a code review for staged/local changes across submodules."""
958971
self._ensureCodeReviewExecutor()
959972
self._codeReviewDiffs.clear()
960-
self._codeReviewScene = "type: staged changes (index)\n"
973+
self._extraContext = "type: staged changes (index)\n"
961974
self._codeReviewExecutor.submit(submodules, self._fetchStagedDiff)
962975

963976
def _ensureCodeReviewExecutor(self):
@@ -1171,7 +1184,7 @@ def _loadMessagesFromHistory(self, messages: List[Dict], addToChatBot=True):
11711184
if role == AiRole.Assistant and isinstance(toolCalls, list) and toolCalls:
11721185
toolCallResult, hasMoreMessages = self._collectToolCallResult(
11731186
i + 1, messages)
1174-
1187+
11751188
for tc in toolCalls:
11761189
if not isinstance(tc, dict):
11771190
continue
@@ -1293,7 +1306,7 @@ def _clearCurrentChat(self):
12931306
self._autoToolGroups.clear()
12941307
self._nextAutoGroupId = 1
12951308
self._codeReviewDiffs.clear()
1296-
self._codeReviewScene = None
1309+
self._extraContext = None
12971310
self.messages.clear()
12981311

12991312
def _setEmbeddedRecentListVisible(self, visible: bool):
@@ -1345,3 +1358,101 @@ def _getToolIcon(toolType: int) -> str:
13451358
@property
13461359
def contextPanel(self) -> AiChatContextPanel:
13471360
return self._contextPanel
1361+
1362+
def resolveConflictSync(self, repoDir: str, sha1: str, path: str, conflictText: str) -> Tuple[bool, Optional[str]]:
1363+
"""Resolve a conflicted file given an excerpt of the working tree file.
1364+
1365+
Returns (ok, reason). On success, ok=True and reason=None.
1366+
"""
1367+
self._waitForInitialization()
1368+
model = self.currentChatModel()
1369+
if not model:
1370+
return False, "no_model"
1371+
1372+
context = (
1373+
f"sha1: {sha1}\n"
1374+
f"conflicted_file: {path}"
1375+
)
1376+
self._extraContext = context
1377+
1378+
prompt = RESOLVE_PROMPT.format(
1379+
conflict=conflictText,
1380+
)
1381+
1382+
self._allowWriteTools = True
1383+
1384+
loop = QEventLoop()
1385+
done: Dict[str, object] = {"ok": False, "reason": None}
1386+
1387+
timer = QTimer()
1388+
timer.setSingleShot(True)
1389+
timer.start(5 * 60 * 1000)
1390+
1391+
oldHistoryCount = len(model.history)
1392+
1393+
def _lastAssistantTextSince(startIndex: int) -> str:
1394+
# Find the latest assistant message text after startIndex.
1395+
history = model.history[startIndex:]
1396+
for h in reversed(history):
1397+
if h.role == AiRole.Assistant:
1398+
if h.message:
1399+
return h.message
1400+
return ""
1401+
1402+
def _finalizeIfIdle():
1403+
# Timeout?
1404+
if timer.remainingTime() <= 0:
1405+
done["ok"] = False
1406+
done["reason"] = "Assistant response timed out"
1407+
model.requestInterruption()
1408+
loop.quit()
1409+
return
1410+
1411+
# If the assistant explicitly reported failure, honor it.
1412+
response = _lastAssistantTextSince(oldHistoryCount)
1413+
if "QGITC_RESOLVE_FAILED:" in response:
1414+
parts = response.split("QGITC_RESOLVE_FAILED:", 1)
1415+
reason = parts[1].strip() if len(parts) > 1 else ""
1416+
done["ok"] = False
1417+
done["reason"] = reason or "Assistant reported failure"
1418+
loop.quit()
1419+
return
1420+
1421+
if "QGITC_RESOLVE_OK" not in response:
1422+
return
1423+
1424+
# Verify the working tree file is conflict-marker-free.
1425+
try:
1426+
absPath = os.path.join(repoDir, path)
1427+
with open(absPath, "r", encoding="utf-8", errors="replace") as f:
1428+
merged = f.read()
1429+
except Exception as e:
1430+
done["ok"] = False
1431+
done["reason"] = f"read_back_failed: {e}"
1432+
loop.quit()
1433+
return
1434+
1435+
if "<<<<<<<" in merged or "=======" in merged or ">>>>>>>" in merged:
1436+
done["ok"] = False
1437+
done["reason"] = "conflict_markers_remain"
1438+
loop.quit()
1439+
return
1440+
1441+
done["ok"] = True
1442+
done["reason"] = None
1443+
loop.quit()
1444+
1445+
timer.timeout.connect(_finalizeIfIdle)
1446+
1447+
# Re-check completion whenever the model finishes a generation step or
1448+
# a tool finishes (tools may trigger another continue-only generation).
1449+
model.finished.connect(_finalizeIfIdle)
1450+
1451+
self._doRequest(prompt, AiChatMode.Agent, RESOLVE_SYS_PROMPT)
1452+
loop.exec()
1453+
1454+
timer.stop()
1455+
model.finished.disconnect(_finalizeIfIdle)
1456+
self._allowWriteTools = False
1457+
1458+
return bool(done.get("ok", False)), done.get("reason")

qgitc/gitutils.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -369,11 +369,11 @@ def externalDiff(branchDir, commit, path=None, tool=None):
369369
process = GitProcess(cwd, args)
370370

371371
@staticmethod
372-
def conflictFiles():
372+
def conflictFiles(repoDir=None):
373373
args = ["diff", "--name-only",
374374
"--diff-filter=U",
375375
"--no-color"]
376-
data = Git.checkOutput(args)
376+
data = Git.checkOutput(args, repoDir=repoDir)
377377
if not data:
378378
return None
379379
return data.rstrip(b'\n').decode("utf-8").split('\n')
@@ -425,17 +425,17 @@ def mergeBranchName():
425425
return name
426426

427427
@staticmethod
428-
def resolveBy(ours, path):
428+
def resolveBy(ours, path, repoDir=None):
429429
args = ["checkout",
430430
"--ours" if ours else "--theirs",
431431
path]
432-
process = Git.run(args)
432+
process = Git.run(args, repoDir=repoDir)
433433
process.communicate()
434434
if process.returncode != 0:
435435
return False
436436

437437
args = ["add", path]
438-
process = Git.run(args)
438+
process = Git.run(args, repoDir=repoDir)
439439
process.communicate()
440440
return True if process.returncode == 0 else False
441441

@@ -1081,3 +1081,23 @@ def isShallowRepo(repoDir=None):
10811081
shallowFile = os.path.join(
10821082
repoDir or Git.REPO_DIR, ".git", "shallow")
10831083
return os.path.exists(shallowFile)
1084+
1085+
@staticmethod
1086+
def getConflictFileBlobIds(filePath, repoDir=None):
1087+
args = ["ls-files", "-u", "--", filePath]
1088+
data = Git.checkOutput(args, repoDir=repoDir)
1089+
if not data:
1090+
return None
1091+
1092+
lines = data.decode("utf-8").split('\n')
1093+
blobIds = {}
1094+
for line in lines:
1095+
if not line.strip():
1096+
continue
1097+
parts = line.split()
1098+
if len(parts) >= 3:
1099+
stage = int(parts[2])
1100+
blobId = parts[1]
1101+
blobIds[stage] = blobId
1102+
1103+
return blobIds

0 commit comments

Comments
 (0)