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.
Files changed (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
gobby/worktrees/git.py ADDED
@@ -0,0 +1,690 @@
1
+ """Git worktree operations manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import subprocess # nosec B404 - subprocess needed for git worktree operations
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class WorktreeInfo:
16
+ """Information about a git worktree."""
17
+
18
+ path: str
19
+ branch: str | None
20
+ commit: str
21
+ is_bare: bool = False
22
+ is_detached: bool = False
23
+ locked: bool = False
24
+ prunable: bool = False
25
+
26
+
27
+ @dataclass
28
+ class WorktreeStatus:
29
+ """Status of a worktree including changes and sync state."""
30
+
31
+ has_uncommitted_changes: bool
32
+ has_staged_changes: bool
33
+ has_untracked_files: bool
34
+ ahead: int # Commits ahead of upstream
35
+ behind: int # Commits behind upstream
36
+ branch: str | None
37
+ commit: str | None
38
+
39
+
40
+ @dataclass
41
+ class GitOperationResult:
42
+ """Result of a git operation."""
43
+
44
+ success: bool
45
+ message: str
46
+ output: str | None = None
47
+ error: str | None = None
48
+
49
+
50
+ class WorktreeGitManager:
51
+ """
52
+ Manager for git worktree operations.
53
+
54
+ Provides methods to create, delete, and manage git worktrees.
55
+ All operations are performed relative to a base repository path.
56
+ """
57
+
58
+ def __init__(self, repo_path: str | Path):
59
+ """
60
+ Initialize with base repository path.
61
+
62
+ Args:
63
+ repo_path: Path to the main git repository
64
+ """
65
+ self.repo_path = Path(repo_path)
66
+ if not self.repo_path.exists():
67
+ raise ValueError(f"Repository path does not exist: {repo_path}")
68
+
69
+ def _run_git(
70
+ self,
71
+ args: list[str],
72
+ cwd: str | Path | None = None,
73
+ timeout: int = 30,
74
+ check: bool = False,
75
+ ) -> subprocess.CompletedProcess[str]:
76
+ """
77
+ Run a git command.
78
+
79
+ Args:
80
+ args: Git command arguments (without 'git' prefix)
81
+ cwd: Working directory (defaults to repo_path)
82
+ timeout: Command timeout in seconds
83
+ check: Raise exception on non-zero exit
84
+
85
+ Returns:
86
+ CompletedProcess with stdout/stderr
87
+ """
88
+ if cwd is None:
89
+ cwd = self.repo_path
90
+
91
+ cmd = ["git"] + args
92
+ logger.debug(f"Running: {' '.join(cmd)} in {cwd}")
93
+
94
+ try:
95
+ result = subprocess.run( # nosec B603 B607 - cmd built from hardcoded git arguments
96
+ cmd,
97
+ cwd=cwd,
98
+ capture_output=True,
99
+ text=True,
100
+ timeout=timeout,
101
+ check=check,
102
+ )
103
+ return result
104
+ except subprocess.TimeoutExpired:
105
+ logger.error(f"Git command timed out: {' '.join(cmd)}")
106
+ raise
107
+ except subprocess.CalledProcessError as e:
108
+ logger.error(f"Git command failed: {' '.join(cmd)}, stderr: {e.stderr}")
109
+ raise
110
+
111
+ def create_worktree(
112
+ self,
113
+ worktree_path: str | Path,
114
+ branch_name: str,
115
+ base_branch: str = "main",
116
+ create_branch: bool = True,
117
+ ) -> GitOperationResult:
118
+ """
119
+ Create a new git worktree.
120
+
121
+ Args:
122
+ worktree_path: Path where worktree will be created
123
+ branch_name: Name of the branch for the worktree
124
+ base_branch: Branch to base the new branch on (if create_branch=True)
125
+ create_branch: Whether to create a new branch or use existing
126
+
127
+ Returns:
128
+ GitOperationResult with success status and message
129
+ """
130
+ worktree_path = Path(worktree_path)
131
+
132
+ # Check if path already exists
133
+ if worktree_path.exists():
134
+ return GitOperationResult(
135
+ success=False,
136
+ message=f"Path already exists: {worktree_path}",
137
+ )
138
+
139
+ # Ensure parent directory exists
140
+ worktree_path.parent.mkdir(parents=True, exist_ok=True)
141
+
142
+ try:
143
+ if create_branch:
144
+ # Create worktree with new branch based on base_branch
145
+ # First, fetch to ensure we have latest refs
146
+ fetch_result = self._run_git(["fetch", "origin", base_branch], timeout=60)
147
+ if fetch_result.returncode != 0:
148
+ return GitOperationResult(
149
+ success=False,
150
+ message=f"Failed to fetch origin/{base_branch}: {fetch_result.stderr}",
151
+ error=fetch_result.stderr,
152
+ )
153
+
154
+ # Create worktree with new branch
155
+ result = self._run_git(
156
+ [
157
+ "worktree",
158
+ "add",
159
+ "-b",
160
+ branch_name,
161
+ str(worktree_path),
162
+ f"origin/{base_branch}",
163
+ ],
164
+ timeout=60,
165
+ )
166
+ else:
167
+ # Use existing branch
168
+ result = self._run_git(
169
+ ["worktree", "add", str(worktree_path), branch_name],
170
+ timeout=60,
171
+ )
172
+
173
+ if result.returncode == 0:
174
+ return GitOperationResult(
175
+ success=True,
176
+ message=f"Created worktree at {worktree_path}",
177
+ output=result.stdout,
178
+ )
179
+ else:
180
+ return GitOperationResult(
181
+ success=False,
182
+ message=f"Failed to create worktree: {result.stderr}",
183
+ error=result.stderr,
184
+ )
185
+
186
+ except subprocess.TimeoutExpired:
187
+ return GitOperationResult(
188
+ success=False,
189
+ message="Git command timed out",
190
+ )
191
+ except Exception as e:
192
+ return GitOperationResult(
193
+ success=False,
194
+ message=f"Error creating worktree: {e}",
195
+ error=str(e),
196
+ )
197
+
198
+ def delete_worktree(
199
+ self,
200
+ worktree_path: str | Path,
201
+ force: bool = False,
202
+ delete_branch: bool = False,
203
+ branch_name: str | None = None,
204
+ ) -> GitOperationResult:
205
+ """
206
+ Delete a git worktree.
207
+
208
+ Args:
209
+ worktree_path: Path to the worktree to delete
210
+ force: Force removal even if dirty
211
+ delete_branch: Also delete the associated branch
212
+ branch_name: Optional explicit branch name (if not provided, attempts to discover)
213
+
214
+ Returns:
215
+ GitOperationResult with success status and message
216
+ """
217
+ worktree_path = Path(worktree_path)
218
+
219
+ try:
220
+ # Get branch name before removal (for optional branch deletion)
221
+ if delete_branch and not branch_name:
222
+ try:
223
+ status = self.get_worktree_status(worktree_path)
224
+ if status:
225
+ branch_name = status.branch
226
+ except Exception:
227
+ # nosec B110 - ignore errors getting status, we just won't have the branch name
228
+ pass
229
+
230
+ # Remove worktree
231
+ args = ["worktree", "remove"]
232
+ if force:
233
+ args.append("--force")
234
+ args.append(str(worktree_path))
235
+
236
+ result = self._run_git(args, timeout=30)
237
+
238
+ if result.returncode != 0:
239
+ return GitOperationResult(
240
+ success=False,
241
+ message=f"Failed to remove worktree: {result.stderr}",
242
+ error=result.stderr,
243
+ )
244
+
245
+ # Optionally delete the branch
246
+ if delete_branch and branch_name:
247
+ branch_result = self._run_git(
248
+ ["branch", "-D" if force else "-d", branch_name],
249
+ timeout=10,
250
+ )
251
+
252
+ if branch_result.returncode != 0:
253
+ return GitOperationResult(
254
+ success=True, # Worktree removed, but branch deletion failed
255
+ message=f"Worktree removed, but failed to delete branch: {branch_result.stderr}",
256
+ error=branch_result.stderr,
257
+ )
258
+
259
+ return GitOperationResult(
260
+ success=True,
261
+ message=f"Deleted worktree at {worktree_path}"
262
+ + (f" and branch {branch_name}" if delete_branch and branch_name else ""),
263
+ output=result.stdout,
264
+ )
265
+
266
+ except subprocess.TimeoutExpired:
267
+ return GitOperationResult(
268
+ success=False,
269
+ message="Git command timed out",
270
+ )
271
+ except Exception as e:
272
+ return GitOperationResult(
273
+ success=False,
274
+ message=f"Error deleting worktree: {e}",
275
+ error=str(e),
276
+ )
277
+
278
+ def sync_from_main(
279
+ self,
280
+ worktree_path: str | Path,
281
+ base_branch: str = "main",
282
+ strategy: Literal["rebase", "merge"] = "rebase",
283
+ ) -> GitOperationResult:
284
+ """
285
+ Sync worktree with base branch.
286
+
287
+ Args:
288
+ worktree_path: Path to the worktree
289
+ base_branch: Branch to sync from
290
+ strategy: Sync strategy (rebase or merge)
291
+
292
+ Returns:
293
+ GitOperationResult with success status and message
294
+ """
295
+ worktree_path = Path(worktree_path)
296
+
297
+ if not worktree_path.exists():
298
+ return GitOperationResult(
299
+ success=False,
300
+ message=f"Worktree path does not exist: {worktree_path}",
301
+ )
302
+
303
+ try:
304
+ # Fetch latest from origin
305
+ fetch_result = self._run_git(
306
+ ["fetch", "origin", base_branch],
307
+ cwd=worktree_path,
308
+ timeout=60,
309
+ )
310
+ if fetch_result.returncode != 0:
311
+ return GitOperationResult(
312
+ success=False,
313
+ message=f"Failed to fetch: {fetch_result.stderr}",
314
+ error=fetch_result.stderr,
315
+ )
316
+
317
+ # Perform rebase or merge
318
+ if strategy == "rebase":
319
+ sync_result = self._run_git(
320
+ ["rebase", f"origin/{base_branch}"],
321
+ cwd=worktree_path,
322
+ timeout=120,
323
+ )
324
+ else:
325
+ sync_result = self._run_git(
326
+ ["merge", f"origin/{base_branch}", "--no-edit"],
327
+ cwd=worktree_path,
328
+ timeout=120,
329
+ )
330
+
331
+ if sync_result.returncode != 0:
332
+ # Check if there are conflicts
333
+ if "CONFLICT" in sync_result.stdout or "CONFLICT" in sync_result.stderr:
334
+ return GitOperationResult(
335
+ success=False,
336
+ message=f"Sync failed due to conflicts. Run 'git {strategy} --abort' to cancel.",
337
+ error=sync_result.stderr or sync_result.stdout,
338
+ )
339
+ return GitOperationResult(
340
+ success=False,
341
+ message=f"Failed to {strategy}: {sync_result.stderr}",
342
+ error=sync_result.stderr,
343
+ )
344
+
345
+ return GitOperationResult(
346
+ success=True,
347
+ message=f"Successfully synced with origin/{base_branch} using {strategy}",
348
+ output=sync_result.stdout,
349
+ )
350
+
351
+ except subprocess.TimeoutExpired:
352
+ return GitOperationResult(
353
+ success=False,
354
+ message="Git command timed out",
355
+ )
356
+ except Exception as e:
357
+ return GitOperationResult(
358
+ success=False,
359
+ message=f"Error syncing worktree: {e}",
360
+ error=str(e),
361
+ )
362
+
363
+ def get_worktree_status(
364
+ self,
365
+ worktree_path: str | Path,
366
+ ) -> WorktreeStatus | None:
367
+ """
368
+ Get status of a worktree.
369
+
370
+ Args:
371
+ worktree_path: Path to the worktree
372
+
373
+ Returns:
374
+ WorktreeStatus or None if path is not valid
375
+ """
376
+ worktree_path = Path(worktree_path)
377
+
378
+ if not worktree_path.exists():
379
+ return None
380
+
381
+ try:
382
+ # Get current branch
383
+ branch_result = self._run_git(
384
+ ["branch", "--show-current"],
385
+ cwd=worktree_path,
386
+ timeout=5,
387
+ )
388
+ branch = branch_result.stdout.strip() if branch_result.returncode == 0 else None
389
+
390
+ # Get current commit
391
+ commit_result = self._run_git(
392
+ ["rev-parse", "--short", "HEAD"],
393
+ cwd=worktree_path,
394
+ timeout=5,
395
+ )
396
+ commit = commit_result.stdout.strip() if commit_result.returncode == 0 else None
397
+
398
+ # Get status (porcelain for parsing)
399
+ status_result = self._run_git(
400
+ ["status", "--porcelain"],
401
+ cwd=worktree_path,
402
+ timeout=10,
403
+ )
404
+
405
+ has_staged = False
406
+ has_uncommitted = False
407
+ has_untracked = False
408
+
409
+ if status_result.returncode == 0:
410
+ for line in status_result.stdout.split("\n"):
411
+ if not line:
412
+ continue
413
+ index_status = line[0] if len(line) > 0 else " "
414
+ worktree_status = line[1] if len(line) > 1 else " "
415
+
416
+ if index_status != " " and index_status != "?":
417
+ has_staged = True
418
+ if worktree_status != " " and worktree_status != "?":
419
+ has_uncommitted = True
420
+ if index_status == "?" or worktree_status == "?":
421
+ has_untracked = True
422
+
423
+ # Get ahead/behind count
424
+ ahead = 0
425
+ behind = 0
426
+
427
+ if branch:
428
+ # Try to get upstream info
429
+ upstream_result = self._run_git(
430
+ ["rev-list", "--count", "--left-right", f"origin/{branch}...HEAD"],
431
+ cwd=worktree_path,
432
+ timeout=10,
433
+ )
434
+ if upstream_result.returncode == 0:
435
+ parts = upstream_result.stdout.strip().split("\t")
436
+ if len(parts) == 2:
437
+ behind = int(parts[0])
438
+ ahead = int(parts[1])
439
+
440
+ return WorktreeStatus(
441
+ has_uncommitted_changes=has_uncommitted,
442
+ has_staged_changes=has_staged,
443
+ has_untracked_files=has_untracked,
444
+ ahead=ahead,
445
+ behind=behind,
446
+ branch=branch,
447
+ commit=commit,
448
+ )
449
+
450
+ except Exception as e:
451
+ logger.error(f"Error getting worktree status: {e}")
452
+ return None
453
+
454
+ def list_worktrees(self) -> list[WorktreeInfo]:
455
+ """
456
+ List all worktrees for this repository.
457
+
458
+ Returns:
459
+ List of WorktreeInfo objects
460
+ """
461
+ try:
462
+ result = self._run_git(
463
+ ["worktree", "list", "--porcelain"],
464
+ timeout=10,
465
+ )
466
+
467
+ if result.returncode != 0:
468
+ logger.error(f"Failed to list worktrees: {result.stderr}")
469
+ return []
470
+
471
+ worktrees = []
472
+ current: dict[str, str | bool] = {}
473
+
474
+ for line in result.stdout.split("\n"):
475
+ if not line:
476
+ if current:
477
+ worktrees.append(
478
+ WorktreeInfo(
479
+ path=str(current.get("worktree", "")),
480
+ branch=current.get("branch"), # type: ignore
481
+ commit=str(current.get("HEAD", "")),
482
+ is_bare=bool(current.get("bare")),
483
+ is_detached=bool(current.get("detached")),
484
+ locked=bool(current.get("locked")),
485
+ prunable=bool(current.get("prunable")),
486
+ )
487
+ )
488
+ current = {}
489
+ continue
490
+
491
+ if line.startswith("worktree "):
492
+ current["worktree"] = line[9:]
493
+ elif line.startswith("HEAD "):
494
+ current["HEAD"] = line[5:]
495
+ elif line.startswith("branch "):
496
+ # refs/heads/branch-name -> branch-name
497
+ branch_ref = line[7:]
498
+ if branch_ref.startswith("refs/heads/"):
499
+ current["branch"] = branch_ref[11:]
500
+ else:
501
+ current["branch"] = branch_ref
502
+ elif line == "bare":
503
+ current["bare"] = True
504
+ elif line == "detached":
505
+ current["detached"] = True
506
+ elif line.startswith("locked"):
507
+ current["locked"] = True
508
+ elif line.startswith("prunable"):
509
+ current["prunable"] = True
510
+
511
+ # Handle last entry
512
+ if current:
513
+ worktrees.append(
514
+ WorktreeInfo(
515
+ path=str(current.get("worktree", "")),
516
+ branch=current.get("branch"), # type: ignore
517
+ commit=str(current.get("HEAD", "")),
518
+ is_bare=bool(current.get("bare")),
519
+ is_detached=bool(current.get("detached")),
520
+ locked=bool(current.get("locked")),
521
+ prunable=bool(current.get("prunable")),
522
+ )
523
+ )
524
+
525
+ return worktrees
526
+
527
+ except Exception as e:
528
+ logger.error(f"Error listing worktrees: {e}")
529
+ return []
530
+
531
+ def prune_worktrees(self) -> GitOperationResult:
532
+ """
533
+ Prune stale worktree entries.
534
+
535
+ Returns:
536
+ GitOperationResult with success status
537
+ """
538
+ try:
539
+ result = self._run_git(["worktree", "prune"], timeout=30)
540
+
541
+ if result.returncode == 0:
542
+ return GitOperationResult(
543
+ success=True,
544
+ message="Pruned stale worktree entries",
545
+ output=result.stdout,
546
+ )
547
+ else:
548
+ return GitOperationResult(
549
+ success=False,
550
+ message=f"Failed to prune: {result.stderr}",
551
+ error=result.stderr,
552
+ )
553
+
554
+ except Exception as e:
555
+ return GitOperationResult(
556
+ success=False,
557
+ message=f"Error pruning worktrees: {e}",
558
+ error=str(e),
559
+ )
560
+
561
+ def lock_worktree(
562
+ self,
563
+ worktree_path: str | Path,
564
+ reason: str | None = None,
565
+ ) -> GitOperationResult:
566
+ """
567
+ Lock a worktree to prevent accidental pruning.
568
+
569
+ Args:
570
+ worktree_path: Path to the worktree
571
+ reason: Optional reason for locking
572
+
573
+ Returns:
574
+ GitOperationResult with success status
575
+ """
576
+ args = ["worktree", "lock", str(worktree_path)]
577
+ if reason:
578
+ args.extend(["--reason", reason])
579
+
580
+ try:
581
+ result = self._run_git(args, timeout=10)
582
+
583
+ if result.returncode == 0:
584
+ return GitOperationResult(
585
+ success=True,
586
+ message=f"Locked worktree at {worktree_path}",
587
+ )
588
+ else:
589
+ return GitOperationResult(
590
+ success=False,
591
+ message=f"Failed to lock: {result.stderr}",
592
+ error=result.stderr,
593
+ )
594
+
595
+ except Exception as e:
596
+ return GitOperationResult(
597
+ success=False,
598
+ message=f"Error locking worktree: {e}",
599
+ error=str(e),
600
+ )
601
+
602
+ def unlock_worktree(self, worktree_path: str | Path) -> GitOperationResult:
603
+ """
604
+ Unlock a worktree.
605
+
606
+ Args:
607
+ worktree_path: Path to the worktree
608
+
609
+ Returns:
610
+ GitOperationResult with success status
611
+ """
612
+ try:
613
+ result = self._run_git(
614
+ ["worktree", "unlock", str(worktree_path)],
615
+ timeout=10,
616
+ )
617
+
618
+ if result.returncode == 0:
619
+ return GitOperationResult(
620
+ success=True,
621
+ message=f"Unlocked worktree at {worktree_path}",
622
+ )
623
+ else:
624
+ return GitOperationResult(
625
+ success=False,
626
+ message=f"Failed to unlock: {result.stderr}",
627
+ error=result.stderr,
628
+ )
629
+
630
+ except Exception as e:
631
+ return GitOperationResult(
632
+ success=False,
633
+ message=f"Error unlocking worktree: {e}",
634
+ error=str(e),
635
+ )
636
+
637
+ def get_default_branch(self) -> str:
638
+ """
639
+ Get the default branch for the repository.
640
+
641
+ Tries multiple methods to detect the default branch:
642
+ 1. Check origin/HEAD symbolic ref (most reliable for cloned repos)
643
+ 2. Check for common default branch names (main, master, develop)
644
+ 3. Fall back to "main" if detection fails
645
+
646
+ Returns:
647
+ Default branch name (e.g., "main", "master", "develop")
648
+ """
649
+ # Method 1: Try to get the default branch from origin/HEAD
650
+ try:
651
+ result = self._run_git(
652
+ ["symbolic-ref", "refs/remotes/origin/HEAD"],
653
+ timeout=5,
654
+ )
655
+ if result.returncode == 0 and result.stdout.strip():
656
+ # Output is like "refs/remotes/origin/main"
657
+ ref = result.stdout.strip()
658
+ if ref.startswith("refs/remotes/origin/"):
659
+ branch = ref[len("refs/remotes/origin/") :]
660
+ logger.debug(f"Detected default branch from origin/HEAD: {branch}")
661
+ return branch
662
+ except Exception:
663
+ pass # nosec B110 - method 1 failed, try next method
664
+
665
+ # Method 2: Check which common default branches exist
666
+ for branch in ["main", "master", "develop"]:
667
+ try:
668
+ # Check if the branch exists locally or remotely
669
+ result = self._run_git(
670
+ ["rev-parse", "--verify", f"refs/heads/{branch}"],
671
+ timeout=5,
672
+ )
673
+ if result.returncode == 0:
674
+ logger.debug(f"Detected default branch from local ref: {branch}")
675
+ return branch
676
+
677
+ # Check remote
678
+ result = self._run_git(
679
+ ["rev-parse", "--verify", f"refs/remotes/origin/{branch}"],
680
+ timeout=5,
681
+ )
682
+ if result.returncode == 0:
683
+ logger.debug(f"Detected default branch from remote ref: {branch}")
684
+ return branch
685
+ except Exception:
686
+ continue # nosec B112 - try next branch if current one fails
687
+
688
+ # Method 3: Fall back to "main"
689
+ logger.debug("Could not detect default branch, falling back to 'main'")
690
+ return "main"
@@ -0,0 +1,20 @@
1
+ """Merge conflict resolution utilities for worktrees."""
2
+
3
+ from gobby.worktrees.merge.conflict_parser import ConflictHunk, extract_conflict_hunks
4
+ from gobby.worktrees.merge.resolver import (
5
+ MergeResolver,
6
+ MergeResult,
7
+ ResolutionResult,
8
+ ResolutionStrategy,
9
+ ResolutionTier,
10
+ )
11
+
12
+ __all__ = [
13
+ "ConflictHunk",
14
+ "extract_conflict_hunks",
15
+ "MergeResolver",
16
+ "MergeResult",
17
+ "ResolutionResult",
18
+ "ResolutionStrategy",
19
+ "ResolutionTier",
20
+ ]