gobby 0.2.5__py3-none-any.whl
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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Conflict extraction utilities for parsing Git merge conflicts.
|
|
2
|
+
|
|
3
|
+
Parses Git conflict markers (<<<<<<< HEAD, =======, >>>>>>> branch) and
|
|
4
|
+
extracts conflict regions with context windowing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ConflictHunk:
|
|
13
|
+
"""A single merge conflict extracted from a file.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
ours: Content from the HEAD/current branch side
|
|
17
|
+
theirs: Content from the incoming/merging branch side
|
|
18
|
+
base: Content from the common ancestor (for diff3-style conflicts)
|
|
19
|
+
start_line: Line number where conflict starts (1-indexed)
|
|
20
|
+
end_line: Line number where conflict ends (1-indexed)
|
|
21
|
+
context_before: Lines of context before the conflict
|
|
22
|
+
context_after: Lines of context after the conflict
|
|
23
|
+
ours_marker: Full <<<<<<< marker line
|
|
24
|
+
theirs_marker: Full >>>>>>> marker line
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
ours: str
|
|
28
|
+
theirs: str
|
|
29
|
+
base: str | None
|
|
30
|
+
start_line: int
|
|
31
|
+
end_line: int
|
|
32
|
+
context_before: str
|
|
33
|
+
context_after: str
|
|
34
|
+
ours_marker: str = ""
|
|
35
|
+
theirs_marker: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Regex patterns for conflict markers
|
|
39
|
+
# Must be at start of line, with optional whitespace before
|
|
40
|
+
CONFLICT_START = re.compile(r"^(<<<<<<<\s+.*)$", re.MULTILINE)
|
|
41
|
+
CONFLICT_SEPARATOR = re.compile(r"^(=======)\s*$", re.MULTILINE)
|
|
42
|
+
CONFLICT_BASE_SEPARATOR = re.compile(r"^(\|\|\|\|\|\|\|\s+.*)$", re.MULTILINE) # diff3 base
|
|
43
|
+
CONFLICT_END = re.compile(r"^(>>>>>>>\s+.*)$", re.MULTILINE)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract_conflict_hunks(file_content: str, context_lines: int = 3) -> list[ConflictHunk]:
|
|
47
|
+
"""Extract conflict hunks from file content.
|
|
48
|
+
|
|
49
|
+
Parses Git conflict markers and extracts conflict regions with
|
|
50
|
+
surrounding context.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
file_content: The full file content with conflict markers
|
|
54
|
+
context_lines: Number of context lines before/after conflict (default: 3)
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List of ConflictHunk objects, one per conflict region.
|
|
58
|
+
Returns empty list if no conflicts found.
|
|
59
|
+
"""
|
|
60
|
+
if not file_content:
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
# Normalize line endings
|
|
64
|
+
content = file_content.replace("\r\n", "\n").replace("\r", "\n")
|
|
65
|
+
lines = content.split("\n")
|
|
66
|
+
|
|
67
|
+
hunks: list[ConflictHunk] = []
|
|
68
|
+
i = 0
|
|
69
|
+
|
|
70
|
+
while i < len(lines):
|
|
71
|
+
line = lines[i]
|
|
72
|
+
|
|
73
|
+
# Look for conflict start marker
|
|
74
|
+
if line.startswith("<<<<<<<"):
|
|
75
|
+
hunk = _parse_conflict_at(lines, i, context_lines)
|
|
76
|
+
if hunk:
|
|
77
|
+
hunks.append(hunk)
|
|
78
|
+
# Skip to end of conflict
|
|
79
|
+
i = hunk.end_line
|
|
80
|
+
else:
|
|
81
|
+
i += 1
|
|
82
|
+
else:
|
|
83
|
+
i += 1
|
|
84
|
+
|
|
85
|
+
return hunks
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_conflict_at(lines: list[str], start_idx: int, context_lines: int) -> ConflictHunk | None:
|
|
89
|
+
"""Parse a single conflict starting at the given line index.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
lines: All lines in the file
|
|
93
|
+
start_idx: Index of the <<<<<<< line
|
|
94
|
+
context_lines: Number of context lines to include
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
ConflictHunk if valid conflict found, None if malformed
|
|
98
|
+
"""
|
|
99
|
+
n = len(lines)
|
|
100
|
+
|
|
101
|
+
# Validate start marker
|
|
102
|
+
if start_idx >= n or not lines[start_idx].startswith("<<<<<<<"):
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
ours_marker = lines[start_idx]
|
|
106
|
+
start_line = start_idx + 1 # Convert to 1-indexed
|
|
107
|
+
|
|
108
|
+
# Find separator (=======)
|
|
109
|
+
separator_idx = None
|
|
110
|
+
base_separator_idx = None
|
|
111
|
+
|
|
112
|
+
for idx in range(start_idx + 1, n):
|
|
113
|
+
line = lines[idx]
|
|
114
|
+
if line.startswith("|||||||"): # diff3 base separator
|
|
115
|
+
base_separator_idx = idx
|
|
116
|
+
elif line.startswith("======="):
|
|
117
|
+
separator_idx = idx
|
|
118
|
+
break
|
|
119
|
+
elif line.startswith(">>>>>>>"):
|
|
120
|
+
# End marker before separator - malformed
|
|
121
|
+
return None
|
|
122
|
+
elif line.startswith("<<<<<<<"):
|
|
123
|
+
# Nested conflict start - malformed
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
if separator_idx is None:
|
|
127
|
+
# No separator found - malformed
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
# Find end marker (>>>>>>>)
|
|
131
|
+
end_idx = None
|
|
132
|
+
for idx in range(separator_idx + 1, n):
|
|
133
|
+
line = lines[idx]
|
|
134
|
+
if line.startswith(">>>>>>>"):
|
|
135
|
+
end_idx = idx
|
|
136
|
+
break
|
|
137
|
+
elif line.startswith("<<<<<<<") or line.startswith("======="):
|
|
138
|
+
# Another conflict start or extra separator - malformed
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
if end_idx is None:
|
|
142
|
+
# No end marker found - malformed
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
theirs_marker = lines[end_idx]
|
|
146
|
+
end_line = end_idx + 1 # Convert to 1-indexed
|
|
147
|
+
|
|
148
|
+
# Extract content sections
|
|
149
|
+
if base_separator_idx is not None:
|
|
150
|
+
# diff3-style: ours | base | theirs
|
|
151
|
+
ours_content = "\n".join(lines[start_idx + 1 : base_separator_idx])
|
|
152
|
+
base_content = "\n".join(lines[base_separator_idx + 1 : separator_idx])
|
|
153
|
+
theirs_content = "\n".join(lines[separator_idx + 1 : end_idx])
|
|
154
|
+
else:
|
|
155
|
+
# Standard: ours | theirs
|
|
156
|
+
ours_content = "\n".join(lines[start_idx + 1 : separator_idx])
|
|
157
|
+
base_content = None
|
|
158
|
+
theirs_content = "\n".join(lines[separator_idx + 1 : end_idx])
|
|
159
|
+
|
|
160
|
+
# Extract context
|
|
161
|
+
context_start = max(0, start_idx - context_lines)
|
|
162
|
+
context_end = min(n, end_idx + 1 + context_lines)
|
|
163
|
+
|
|
164
|
+
context_before = "\n".join(lines[context_start:start_idx]) if start_idx > 0 else ""
|
|
165
|
+
context_after = "\n".join(lines[end_idx + 1 : context_end]) if end_idx + 1 < n else ""
|
|
166
|
+
|
|
167
|
+
return ConflictHunk(
|
|
168
|
+
ours=ours_content,
|
|
169
|
+
theirs=theirs_content,
|
|
170
|
+
base=base_content,
|
|
171
|
+
start_line=start_line,
|
|
172
|
+
end_line=end_line,
|
|
173
|
+
context_before=context_before,
|
|
174
|
+
context_after=context_after,
|
|
175
|
+
ours_marker=ours_marker,
|
|
176
|
+
theirs_marker=theirs_marker,
|
|
177
|
+
)
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Merge conflict resolver with tiered resolution strategy.
|
|
2
|
+
|
|
3
|
+
Implements a four-tier resolution strategy:
|
|
4
|
+
1. Git auto-merge (no conflicts)
|
|
5
|
+
2. Conflict-only AI resolution (sends only conflict hunks to LLM)
|
|
6
|
+
3. Full-file AI resolution (sends entire file for complex conflicts)
|
|
7
|
+
4. Human review fallback (marks as needs-human-review)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from gobby.llm.service import LLMService
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ResolutionTier(Enum):
|
|
24
|
+
"""Resolution strategy tiers, from fastest to most expensive."""
|
|
25
|
+
|
|
26
|
+
GIT_AUTO = "git_auto"
|
|
27
|
+
CONFLICT_ONLY_AI = "conflict_only_ai"
|
|
28
|
+
FULL_FILE_AI = "full_file_ai"
|
|
29
|
+
HUMAN_REVIEW = "human_review"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Alias for spec compatibility
|
|
33
|
+
ResolutionStrategy = ResolutionTier
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class MergeResult:
|
|
38
|
+
"""Result of a merge resolution attempt.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
success: Whether the merge was fully resolved
|
|
42
|
+
tier: The tier that completed the resolution (or escalated to)
|
|
43
|
+
conflicts: List of conflicts found during merge
|
|
44
|
+
resolved_files: List of files that were successfully resolved
|
|
45
|
+
unresolved_conflicts: List of conflicts that could not be resolved
|
|
46
|
+
needs_human_review: Whether manual intervention is required
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
success: bool
|
|
50
|
+
tier: ResolutionTier
|
|
51
|
+
conflicts: list[dict[str, Any]]
|
|
52
|
+
resolved_files: list[str] = field(default_factory=list)
|
|
53
|
+
unresolved_conflicts: list[dict[str, Any]] = field(default_factory=list)
|
|
54
|
+
needs_human_review: bool = False
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
"""Convert to dictionary for serialization."""
|
|
58
|
+
return {
|
|
59
|
+
"success": self.success,
|
|
60
|
+
"tier": self.tier.value,
|
|
61
|
+
"conflicts": self.conflicts,
|
|
62
|
+
"resolved_files": self.resolved_files,
|
|
63
|
+
"unresolved_conflicts": self.unresolved_conflicts,
|
|
64
|
+
"needs_human_review": self.needs_human_review,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Alias for spec compatibility
|
|
69
|
+
ResolutionResult = MergeResult
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MergeResolver:
|
|
73
|
+
"""Merge conflict resolver with tiered strategy.
|
|
74
|
+
|
|
75
|
+
Attempts resolution in order of increasing complexity/cost:
|
|
76
|
+
1. Git auto-merge
|
|
77
|
+
2. Conflict-only AI resolution
|
|
78
|
+
3. Full-file AI resolution
|
|
79
|
+
4. Human review fallback
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
conflict_size_threshold: int = 100,
|
|
85
|
+
max_parallel_files: int = 5,
|
|
86
|
+
):
|
|
87
|
+
"""Initialize MergeResolver.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
conflict_size_threshold: Lines of conflict above which to escalate to full-file
|
|
91
|
+
max_parallel_files: Maximum files to resolve in parallel
|
|
92
|
+
"""
|
|
93
|
+
self.conflict_size_threshold = conflict_size_threshold
|
|
94
|
+
self.max_parallel_files = max_parallel_files
|
|
95
|
+
self._llm_service: LLMService | None = None # LLM service integration point
|
|
96
|
+
|
|
97
|
+
async def resolve_file(
|
|
98
|
+
self,
|
|
99
|
+
path: Path | str,
|
|
100
|
+
conflict_hunks: list[Any],
|
|
101
|
+
) -> "ResolutionResult":
|
|
102
|
+
"""Resolve conflicts in a single file using tiered strategy.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
path: Path to the file with conflicts
|
|
106
|
+
conflict_hunks: List of ConflictHunk objects or conflict dicts
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
ResolutionResult with resolution status
|
|
110
|
+
"""
|
|
111
|
+
file_path = str(path) if isinstance(path, Path) else path
|
|
112
|
+
|
|
113
|
+
# Convert hunks to conflict dict format
|
|
114
|
+
conflict = {
|
|
115
|
+
"file": file_path,
|
|
116
|
+
"hunks": conflict_hunks,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Check if conflict is too large for conflict-only resolution
|
|
120
|
+
def get_hunk_lines(h: Any) -> int:
|
|
121
|
+
"""Get line count from hunk, handling both objects and dicts."""
|
|
122
|
+
if isinstance(h, dict):
|
|
123
|
+
ours = h.get("ours", "")
|
|
124
|
+
theirs = h.get("theirs", "")
|
|
125
|
+
else:
|
|
126
|
+
ours = getattr(h, "ours", "")
|
|
127
|
+
theirs = getattr(h, "theirs", "")
|
|
128
|
+
return len(ours.split("\n")) + len(theirs.split("\n"))
|
|
129
|
+
|
|
130
|
+
total_lines = sum(get_hunk_lines(h) for h in conflict_hunks)
|
|
131
|
+
|
|
132
|
+
# Tier 2: Try conflict-only if under threshold
|
|
133
|
+
if total_lines <= self.conflict_size_threshold:
|
|
134
|
+
result = await self._resolve_conflicts_only([conflict])
|
|
135
|
+
if result["success"]:
|
|
136
|
+
return ResolutionResult(
|
|
137
|
+
success=True,
|
|
138
|
+
tier=ResolutionTier.CONFLICT_ONLY_AI,
|
|
139
|
+
conflicts=[conflict],
|
|
140
|
+
resolved_files=[file_path],
|
|
141
|
+
unresolved_conflicts=[],
|
|
142
|
+
needs_human_review=False,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Tier 3: Full-file resolution
|
|
146
|
+
result = await self._resolve_full_file([conflict])
|
|
147
|
+
if result["success"]:
|
|
148
|
+
return ResolutionResult(
|
|
149
|
+
success=True,
|
|
150
|
+
tier=ResolutionTier.FULL_FILE_AI,
|
|
151
|
+
conflicts=[conflict],
|
|
152
|
+
resolved_files=[file_path],
|
|
153
|
+
unresolved_conflicts=[],
|
|
154
|
+
needs_human_review=False,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Tier 4: Human review fallback
|
|
158
|
+
return ResolutionResult(
|
|
159
|
+
success=False,
|
|
160
|
+
tier=ResolutionTier.HUMAN_REVIEW,
|
|
161
|
+
conflicts=[conflict],
|
|
162
|
+
resolved_files=[],
|
|
163
|
+
unresolved_conflicts=[conflict],
|
|
164
|
+
needs_human_review=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
async def resolve(
|
|
168
|
+
self,
|
|
169
|
+
worktree_path: str,
|
|
170
|
+
source_branch: str,
|
|
171
|
+
target_branch: str,
|
|
172
|
+
force_tier: ResolutionTier | None = None,
|
|
173
|
+
) -> MergeResult:
|
|
174
|
+
"""Resolve merge conflicts using tiered strategy.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
worktree_path: Path to the git worktree
|
|
178
|
+
source_branch: Branch being merged in
|
|
179
|
+
target_branch: Target branch (e.g., main)
|
|
180
|
+
force_tier: Optional tier to force (skips lower tiers)
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
MergeResult with resolution status and details
|
|
184
|
+
"""
|
|
185
|
+
# Tier 1: Git auto-merge (unless forcing a higher tier)
|
|
186
|
+
if force_tier is None or force_tier == ResolutionTier.GIT_AUTO:
|
|
187
|
+
git_result = await self._git_merge(worktree_path, source_branch, target_branch)
|
|
188
|
+
|
|
189
|
+
if git_result["success"]:
|
|
190
|
+
return MergeResult(
|
|
191
|
+
success=True,
|
|
192
|
+
tier=ResolutionTier.GIT_AUTO,
|
|
193
|
+
conflicts=[],
|
|
194
|
+
resolved_files=[],
|
|
195
|
+
unresolved_conflicts=[],
|
|
196
|
+
needs_human_review=False,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
conflicts = git_result.get("conflicts", [])
|
|
200
|
+
else:
|
|
201
|
+
# Skipping git merge, assume conflicts exist
|
|
202
|
+
conflicts = []
|
|
203
|
+
|
|
204
|
+
# If forcing full-file AI, skip tier 2
|
|
205
|
+
if force_tier == ResolutionTier.FULL_FILE_AI:
|
|
206
|
+
return await self._try_full_file_resolution(worktree_path, conflicts or [{}])
|
|
207
|
+
|
|
208
|
+
# Tier 2: Conflict-only AI resolution
|
|
209
|
+
if conflicts:
|
|
210
|
+
tier2_result = await self._resolve_conflicts_only(conflicts)
|
|
211
|
+
|
|
212
|
+
if tier2_result["success"]:
|
|
213
|
+
return MergeResult(
|
|
214
|
+
success=True,
|
|
215
|
+
tier=ResolutionTier.CONFLICT_ONLY_AI,
|
|
216
|
+
conflicts=conflicts,
|
|
217
|
+
resolved_files=[c.get("file", "") for c in conflicts],
|
|
218
|
+
unresolved_conflicts=[],
|
|
219
|
+
needs_human_review=False,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Tier 3: Full-file AI resolution
|
|
223
|
+
return await self._try_full_file_resolution(worktree_path, conflicts)
|
|
224
|
+
|
|
225
|
+
# No conflicts from git, but no git result - unusual state
|
|
226
|
+
return MergeResult(
|
|
227
|
+
success=True,
|
|
228
|
+
tier=ResolutionTier.GIT_AUTO,
|
|
229
|
+
conflicts=[],
|
|
230
|
+
resolved_files=[],
|
|
231
|
+
unresolved_conflicts=[],
|
|
232
|
+
needs_human_review=False,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def _try_full_file_resolution(
|
|
236
|
+
self,
|
|
237
|
+
worktree_path: str,
|
|
238
|
+
conflicts: list[dict[str, Any]],
|
|
239
|
+
) -> MergeResult:
|
|
240
|
+
"""Attempt Tier 3 full-file resolution, fallback to human review."""
|
|
241
|
+
tier3_result = await self._resolve_full_file(conflicts)
|
|
242
|
+
|
|
243
|
+
if tier3_result["success"]:
|
|
244
|
+
return MergeResult(
|
|
245
|
+
success=True,
|
|
246
|
+
tier=ResolutionTier.FULL_FILE_AI,
|
|
247
|
+
conflicts=conflicts,
|
|
248
|
+
resolved_files=[c.get("file", "") for c in conflicts],
|
|
249
|
+
unresolved_conflicts=[],
|
|
250
|
+
needs_human_review=False,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Tier 4: Human review fallback
|
|
254
|
+
return MergeResult(
|
|
255
|
+
success=False,
|
|
256
|
+
tier=ResolutionTier.HUMAN_REVIEW,
|
|
257
|
+
conflicts=conflicts,
|
|
258
|
+
resolved_files=[],
|
|
259
|
+
unresolved_conflicts=conflicts,
|
|
260
|
+
needs_human_review=True,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
async def _git_merge(
|
|
264
|
+
self,
|
|
265
|
+
worktree_path: str,
|
|
266
|
+
source_branch: str,
|
|
267
|
+
target_branch: str,
|
|
268
|
+
) -> dict[str, Any]:
|
|
269
|
+
"""Attempt git auto-merge.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
worktree_path: Path to git worktree
|
|
273
|
+
source_branch: Branch to merge in
|
|
274
|
+
target_branch: Target branch
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Dict with 'success' bool and 'conflicts' list if any
|
|
278
|
+
"""
|
|
279
|
+
# Run git merge without committing
|
|
280
|
+
process = await asyncio.create_subprocess_exec(
|
|
281
|
+
"git",
|
|
282
|
+
"merge",
|
|
283
|
+
"--no-commit",
|
|
284
|
+
"--no-ff",
|
|
285
|
+
source_branch,
|
|
286
|
+
cwd=worktree_path,
|
|
287
|
+
stdout=asyncio.subprocess.PIPE,
|
|
288
|
+
stderr=asyncio.subprocess.PIPE,
|
|
289
|
+
)
|
|
290
|
+
await process.communicate()
|
|
291
|
+
|
|
292
|
+
if process.returncode == 0:
|
|
293
|
+
return {"success": True, "conflicts": []}
|
|
294
|
+
|
|
295
|
+
# Merge failed, find conflicting files
|
|
296
|
+
diff_process = await asyncio.create_subprocess_exec(
|
|
297
|
+
"git",
|
|
298
|
+
"diff",
|
|
299
|
+
"--name-only",
|
|
300
|
+
"--diff-filter=U",
|
|
301
|
+
cwd=worktree_path,
|
|
302
|
+
stdout=asyncio.subprocess.PIPE,
|
|
303
|
+
stderr=asyncio.subprocess.PIPE,
|
|
304
|
+
)
|
|
305
|
+
stdout, _ = await diff_process.communicate()
|
|
306
|
+
conflicted_files = stdout.decode().strip().splitlines()
|
|
307
|
+
|
|
308
|
+
from gobby.worktrees.merge.conflict_parser import extract_conflict_hunks
|
|
309
|
+
|
|
310
|
+
conflicts = []
|
|
311
|
+
for file_rel_path in conflicted_files:
|
|
312
|
+
file_path = Path(worktree_path) / file_rel_path
|
|
313
|
+
try:
|
|
314
|
+
content = file_path.read_text()
|
|
315
|
+
hunks = extract_conflict_hunks(content)
|
|
316
|
+
if hunks:
|
|
317
|
+
conflicts.append({"file": str(file_rel_path), "hunks": hunks})
|
|
318
|
+
except Exception as e:
|
|
319
|
+
logger.error(f"Failed to parse conflicts in {file_rel_path}: {e}")
|
|
320
|
+
|
|
321
|
+
return {"success": False, "conflicts": conflicts}
|
|
322
|
+
|
|
323
|
+
async def _resolve_conflicts_only(
|
|
324
|
+
self,
|
|
325
|
+
conflicts: list[dict[str, Any]],
|
|
326
|
+
) -> dict[str, Any]:
|
|
327
|
+
"""Resolve conflicts by sending only conflict hunks to LLM.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
conflicts: List of conflict dicts with hunks
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
Dict with 'success' bool and 'resolutions' list
|
|
334
|
+
"""
|
|
335
|
+
if not self._llm_service:
|
|
336
|
+
logger.warning("No LLM service available for resolution")
|
|
337
|
+
return {"success": False, "resolutions": []}
|
|
338
|
+
|
|
339
|
+
resolutions = []
|
|
340
|
+
for conflict in conflicts:
|
|
341
|
+
file_path = conflict.get("file", "unknown")
|
|
342
|
+
hunks = conflict.get("hunks", [])
|
|
343
|
+
|
|
344
|
+
prompt = f"Resolve the following merge conflicts in {file_path}. Return ONLY the resolved code content for each hunk.\n\n"
|
|
345
|
+
|
|
346
|
+
for i, hunk in enumerate(hunks):
|
|
347
|
+
# Handle both dict and object hunk formats
|
|
348
|
+
if isinstance(hunk, dict):
|
|
349
|
+
ours = hunk.get("ours", "")
|
|
350
|
+
theirs = hunk.get("theirs", "")
|
|
351
|
+
else:
|
|
352
|
+
ours = getattr(hunk, "ours", "")
|
|
353
|
+
theirs = getattr(hunk, "theirs", "")
|
|
354
|
+
|
|
355
|
+
prompt += f"CONFLICT {i + 1}:\n"
|
|
356
|
+
prompt += f"<<<<<<< HEAD\n{ours}\n=======\n{theirs}\n>>>>>>> INCOMING\n\n"
|
|
357
|
+
|
|
358
|
+
prompt += "Provide the resolved code for each conflict hunk, separated by '---HUNK SEPARATOR---'."
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
# Use default provider for now, could be configurable via tiered strategy params
|
|
362
|
+
provider = self._llm_service.get_default_provider()
|
|
363
|
+
response = await provider.generate_text(prompt)
|
|
364
|
+
|
|
365
|
+
if response:
|
|
366
|
+
# Simple parsing assumption - in real app would be more robust
|
|
367
|
+
resolved_hunks = response.split("---HUNK SEPARATOR---")
|
|
368
|
+
resolutions.append(
|
|
369
|
+
{
|
|
370
|
+
"file": file_path,
|
|
371
|
+
"content": response, # Storing full response for now as simple implementation
|
|
372
|
+
"hunks_resolved": len(resolved_hunks),
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
else:
|
|
376
|
+
return {"success": False, "resolutions": []}
|
|
377
|
+
except Exception as e:
|
|
378
|
+
logger.error(f"LLM resolution failed for {file_path}: {e}")
|
|
379
|
+
return {"success": False, "resolutions": []}
|
|
380
|
+
|
|
381
|
+
return {"success": True, "resolutions": resolutions}
|
|
382
|
+
|
|
383
|
+
async def _resolve_full_file(
|
|
384
|
+
self,
|
|
385
|
+
conflicts: list[dict[str, Any]],
|
|
386
|
+
) -> dict[str, Any]:
|
|
387
|
+
"""Resolve conflicts by sending full file content to LLM.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
conflicts: List of conflict dicts
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Dict with 'success' bool and 'resolutions' list
|
|
394
|
+
"""
|
|
395
|
+
if not self._llm_service:
|
|
396
|
+
logger.warning("No LLM service available for resolution")
|
|
397
|
+
return {"success": False, "resolutions": []}
|
|
398
|
+
|
|
399
|
+
resolutions = []
|
|
400
|
+
for conflict in conflicts:
|
|
401
|
+
file_path = conflict.get("file", "unknown")
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
# In a real scenario, we'd read the file content with markers here
|
|
405
|
+
# But typically the file on disk already has markers if git merge failed
|
|
406
|
+
content_with_markers = Path(file_path).read_text()
|
|
407
|
+
|
|
408
|
+
prompt = f"Resolve all merge conflicts in the following file {file_path}. Return the FULL resolved file content.\n\n"
|
|
409
|
+
prompt += content_with_markers
|
|
410
|
+
|
|
411
|
+
# Use default provider
|
|
412
|
+
provider = self._llm_service.get_default_provider()
|
|
413
|
+
response = await provider.generate_text(prompt)
|
|
414
|
+
|
|
415
|
+
if response:
|
|
416
|
+
resolutions.append({"file": file_path, "content": response})
|
|
417
|
+
else:
|
|
418
|
+
return {"success": False, "resolutions": []}
|
|
419
|
+
except Exception as e:
|
|
420
|
+
logger.error(f"Full file resolution failed for {file_path}: {e}")
|
|
421
|
+
return {"success": False, "resolutions": []}
|
|
422
|
+
|
|
423
|
+
return {"success": True, "resolutions": resolutions}
|
|
424
|
+
|
|
425
|
+
async def _resolve_file_conflict(
|
|
426
|
+
self,
|
|
427
|
+
conflict: dict[str, Any],
|
|
428
|
+
) -> dict[str, Any]:
|
|
429
|
+
"""Resolve a single file's conflicts.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
conflict: Conflict dict for one file
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Dict with 'success' bool
|
|
436
|
+
"""
|
|
437
|
+
# Try conflict-only first
|
|
438
|
+
result = await self._resolve_conflicts_only([conflict])
|
|
439
|
+
if result["success"]:
|
|
440
|
+
return {"success": True}
|
|
441
|
+
|
|
442
|
+
# Escalate to full-file
|
|
443
|
+
result = await self._resolve_full_file([conflict])
|
|
444
|
+
return result
|
|
445
|
+
|
|
446
|
+
async def resolve_conflicts_parallel(
|
|
447
|
+
self,
|
|
448
|
+
worktree_path: str,
|
|
449
|
+
conflicts: list[dict[str, Any]],
|
|
450
|
+
) -> tuple[list[str], list[dict[str, Any]]]:
|
|
451
|
+
"""Resolve multiple file conflicts in parallel.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
worktree_path: Path to git worktree
|
|
455
|
+
conflicts: List of conflicts to resolve
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
Tuple of (resolved_files, unresolved_conflicts)
|
|
459
|
+
"""
|
|
460
|
+
semaphore = asyncio.Semaphore(self.max_parallel_files)
|
|
461
|
+
|
|
462
|
+
async def resolve_with_limit(conflict: dict[str, Any]) -> dict[str, Any]:
|
|
463
|
+
async with semaphore:
|
|
464
|
+
result = await self._resolve_file_conflict(conflict)
|
|
465
|
+
return {"conflict": conflict, "result": result}
|
|
466
|
+
|
|
467
|
+
tasks = [resolve_with_limit(c) for c in conflicts]
|
|
468
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
469
|
+
|
|
470
|
+
resolved_files: list[str] = []
|
|
471
|
+
unresolved: list[dict[str, Any]] = []
|
|
472
|
+
|
|
473
|
+
for r in results:
|
|
474
|
+
if isinstance(r, BaseException):
|
|
475
|
+
logger.error(f"Error resolving conflict: {r}")
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
# r is now dict[str, Any] after the isinstance check
|
|
479
|
+
result_dict: dict[str, Any] = r
|
|
480
|
+
if result_dict["result"].get("success"):
|
|
481
|
+
resolved_files.append(result_dict["conflict"].get("file", ""))
|
|
482
|
+
else:
|
|
483
|
+
unresolved.append(result_dict["conflict"])
|
|
484
|
+
|
|
485
|
+
return resolved_files, unresolved
|