marvisx-cli 0.1.0__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 (587) hide show
  1. core/api/__init__.py +0 -0
  2. core/api/agents/__init__.py +0 -0
  3. core/api/agents/session_health.py +59 -0
  4. core/api/agents/session_manager.py +206 -0
  5. core/api/bin/marvisx-state-hook.py +182 -0
  6. core/api/config.py +533 -0
  7. core/api/db.py +1516 -0
  8. core/api/dependencies/__init__.py +0 -0
  9. core/api/dependencies/tenant.py +34 -0
  10. core/api/main.py +1641 -0
  11. core/api/mcp/__init__.py +8 -0
  12. core/api/mcp/_adapter.py +184 -0
  13. core/api/mcp/server.py +58 -0
  14. core/api/mcp/tools/__init__.py +59 -0
  15. core/api/mcp/tools/brain.py +599 -0
  16. core/api/mcp/tools/graph.py +380 -0
  17. core/api/mcp/tools/handoffs.py +112 -0
  18. core/api/mcp/tools/ingest.py +326 -0
  19. core/api/mcp/tools/learnings.py +144 -0
  20. core/api/mcp/tools/projects.py +99 -0
  21. core/api/mcp/tools/pull_requests.py +173 -0
  22. core/api/mcp/tools/safety.py +111 -0
  23. core/api/mcp/tools/search.py +79 -0
  24. core/api/mcp/tools/tasks.py +258 -0
  25. core/api/middleware/__init__.py +0 -0
  26. core/api/middleware/tool_call_audit.py +111 -0
  27. core/api/models/__init__.py +346 -0
  28. core/api/models/auth.py +48 -0
  29. core/api/models/brain.py +1006 -0
  30. core/api/models/common.py +76 -0
  31. core/api/models/costs.py +91 -0
  32. core/api/models/graph.py +66 -0
  33. core/api/models/graph_cosmo.py +125 -0
  34. core/api/models/graph_pr_impact.py +257 -0
  35. core/api/models/graph_ux.py +141 -0
  36. core/api/models/inbox.py +230 -0
  37. core/api/models/ingest_keys.py +108 -0
  38. core/api/models/kg.py +41 -0
  39. core/api/models/llm_config.py +56 -0
  40. core/api/models/monitoring.py +234 -0
  41. core/api/models/projects.py +161 -0
  42. core/api/models/search.py +42 -0
  43. core/api/models/sessions.py +322 -0
  44. core/api/models/tasks.py +184 -0
  45. core/api/models/teams.py +63 -0
  46. core/api/models/users.py +184 -0
  47. core/api/observability/__init__.py +0 -0
  48. core/api/observability/tracing.py +92 -0
  49. core/api/paths.py +26 -0
  50. core/api/rate_limit.py +24 -0
  51. core/api/rbac.py +112 -0
  52. core/api/routers/__init__.py +0 -0
  53. core/api/routers/_adapter.py +24 -0
  54. core/api/routers/admin_pr_impact.py +230 -0
  55. core/api/routers/admin_settings.py +147 -0
  56. core/api/routers/agent.py +1079 -0
  57. core/api/routers/agent_tokens.py +276 -0
  58. core/api/routers/app_settings.py +112 -0
  59. core/api/routers/audit.py +89 -0
  60. core/api/routers/auth.py +586 -0
  61. core/api/routers/bench.py +161 -0
  62. core/api/routers/brain.py +881 -0
  63. core/api/routers/brain_directions.py +527 -0
  64. core/api/routers/ci_checks.py +140 -0
  65. core/api/routers/comments.py +273 -0
  66. core/api/routers/costs.py +148 -0
  67. core/api/routers/docs_coverage.py +217 -0
  68. core/api/routers/docs_governance.py +63 -0
  69. core/api/routers/documents.py +318 -0
  70. core/api/routers/files.py +163 -0
  71. core/api/routers/finder.py +987 -0
  72. core/api/routers/graph.py +836 -0
  73. core/api/routers/handoffs.py +156 -0
  74. core/api/routers/inbox.py +496 -0
  75. core/api/routers/ingest_api_keys.py +205 -0
  76. core/api/routers/ingest_triage.py +1227 -0
  77. core/api/routers/judge.py +306 -0
  78. core/api/routers/kg.py +336 -0
  79. core/api/routers/learnings.py +253 -0
  80. core/api/routers/llm_config.py +130 -0
  81. core/api/routers/monitoring.py +347 -0
  82. core/api/routers/notifications.py +125 -0
  83. core/api/routers/pr_impact.py +315 -0
  84. core/api/routers/projects.py +1061 -0
  85. core/api/routers/pull_requests.py +312 -0
  86. core/api/routers/push.py +67 -0
  87. core/api/routers/raci.py +228 -0
  88. core/api/routers/search.py +125 -0
  89. core/api/routers/sessions.py +3100 -0
  90. core/api/routers/settings.py +90 -0
  91. core/api/routers/share_repo.py +68 -0
  92. core/api/routers/status_updates.py +96 -0
  93. core/api/routers/tags.py +45 -0
  94. core/api/routers/tasks.py +526 -0
  95. core/api/routers/teams.py +425 -0
  96. core/api/routers/terminal.py +105 -0
  97. core/api/routers/users.py +331 -0
  98. core/api/routers/webhooks.py +330 -0
  99. core/api/runtime_settings.py +84 -0
  100. core/api/security.py +652 -0
  101. core/api/services/__init__.py +0 -0
  102. core/api/services/audit.py +58 -0
  103. core/api/services/auto_approval.py +82 -0
  104. core/api/services/brain/__init__.py +52 -0
  105. core/api/services/brain/baseline.py +230 -0
  106. core/api/services/brain/capabilities.py +75 -0
  107. core/api/services/brain/cascade_rollup.py +388 -0
  108. core/api/services/brain/compound_bridge.py +215 -0
  109. core/api/services/brain/cycle.py +1242 -0
  110. core/api/services/brain/cycle_snapshot.py +371 -0
  111. core/api/services/brain/digest_collector.py +147 -0
  112. core/api/services/brain/direction.py +421 -0
  113. core/api/services/brain/drift.py +356 -0
  114. core/api/services/brain/drift_router.py +409 -0
  115. core/api/services/brain/edge_metrics.py +79 -0
  116. core/api/services/brain/events_reader.py +222 -0
  117. core/api/services/brain/findings.py +1379 -0
  118. core/api/services/brain/findings_reader.py +1006 -0
  119. core/api/services/brain/jobs.py +733 -0
  120. core/api/services/brain/journal.py +206 -0
  121. core/api/services/brain/knowledge_forms.py +92 -0
  122. core/api/services/brain/llm/__init__.py +37 -0
  123. core/api/services/brain/llm/_runner.py +62 -0
  124. core/api/services/brain/llm/base.py +70 -0
  125. core/api/services/brain/llm/cache.py +99 -0
  126. core/api/services/brain/llm/constants.py +46 -0
  127. core/api/services/brain/llm/direction_alignment.py +289 -0
  128. core/api/services/brain/llm/factory.py +132 -0
  129. core/api/services/brain/llm/finding_reasoning.py +98 -0
  130. core/api/services/brain/llm/finding_summary.py +92 -0
  131. core/api/services/brain/llm/grounding.py +46 -0
  132. core/api/services/brain/llm/journal_polish.py +96 -0
  133. core/api/services/brain/llm/local_gateway.py +426 -0
  134. core/api/services/brain/llm/parsers.py +71 -0
  135. core/api/services/brain/llm/router_glue.py +422 -0
  136. core/api/services/brain/memory_ops.py +1677 -0
  137. core/api/services/brain/models.py +140 -0
  138. core/api/services/brain/owner_hint.py +211 -0
  139. core/api/services/brain/recap.py +307 -0
  140. core/api/services/brain/rules/__init__.py +65 -0
  141. core/api/services/brain/rules/_signals.py +205 -0
  142. core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
  143. core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
  144. core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
  145. core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
  146. core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
  147. core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
  148. core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
  149. core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
  150. core/api/services/brain/runs_reader.py +485 -0
  151. core/api/services/brain/scope.py +79 -0
  152. core/api/services/brain/sources/__init__.py +46 -0
  153. core/api/services/brain/sources/base.py +86 -0
  154. core/api/services/brain/sources/git_kg.py +393 -0
  155. core/api/services/brain/sources/handoffs.py +157 -0
  156. core/api/services/brain/sources/ingestor.py +130 -0
  157. core/api/services/brain/sources/learnings.py +121 -0
  158. core/api/services/brain/sources/pir_tasks.py +245 -0
  159. core/api/services/brain/watermarks.py +147 -0
  160. core/api/services/brain/ws_emitter.py +170 -0
  161. core/api/services/cc_tasks_reader.py +76 -0
  162. core/api/services/ci_service.py +263 -0
  163. core/api/services/claude_metrics.py +796 -0
  164. core/api/services/codex_metrics.py +364 -0
  165. core/api/services/conversation_reader.py +102 -0
  166. core/api/services/cost_service.py +243 -0
  167. core/api/services/crypto.py +147 -0
  168. core/api/services/docs_governance/__init__.py +1 -0
  169. core/api/services/docs_governance/confidence.py +230 -0
  170. core/api/services/docs_governance/config.py +87 -0
  171. core/api/services/docs_governance/enrichment.py +65 -0
  172. core/api/services/docs_governance/frontmatter_validator.py +83 -0
  173. core/api/services/docs_governance/hard_gates.py +221 -0
  174. core/api/services/docs_governance/triage_orchestrator.py +98 -0
  175. core/api/services/embedding_internal.py +395 -0
  176. core/api/services/embedding_service.py +832 -0
  177. core/api/services/event_dispatcher.py +167 -0
  178. core/api/services/events.py +70 -0
  179. core/api/services/git_ops.py +621 -0
  180. core/api/services/graph_cosmo_service.py +440 -0
  181. core/api/services/graph_ranker.py +306 -0
  182. core/api/services/graph_service.py +1589 -0
  183. core/api/services/inbox.py +800 -0
  184. core/api/services/inbox_digest.py +221 -0
  185. core/api/services/inbox_digest_deep_research.py +80 -0
  186. core/api/services/inbox_digest_jobs.py +595 -0
  187. core/api/services/inbox_gmail_sync.py +167 -0
  188. core/api/services/inbox_llm_classifier.py +906 -0
  189. core/api/services/inbox_source_identity.py +116 -0
  190. core/api/services/inbox_sources.py +456 -0
  191. core/api/services/inbox_taxonomy.py +195 -0
  192. core/api/services/inbox_tldr.py +1079 -0
  193. core/api/services/inbox_triage.py +899 -0
  194. core/api/services/ingest/__init__.py +13 -0
  195. core/api/services/ingest/api_key_auth.py +136 -0
  196. core/api/services/ingest/auto_approve.py +120 -0
  197. core/api/services/ingest/classifier.py +138 -0
  198. core/api/services/ingest/confidence.py +173 -0
  199. core/api/services/ingest/dispatch.py +88 -0
  200. core/api/services/ingest/embedding_router.py +272 -0
  201. core/api/services/ingest/events.py +33 -0
  202. core/api/services/ingest/ignore_patterns.py +79 -0
  203. core/api/services/ingest/image_probe.py +218 -0
  204. core/api/services/ingest/ingress.py +263 -0
  205. core/api/services/ingest/insert_saga.py +793 -0
  206. core/api/services/ingest/llm/__init__.py +13 -0
  207. core/api/services/ingest/llm/anthropic_haiku.py +23 -0
  208. core/api/services/ingest/llm/base.py +59 -0
  209. core/api/services/ingest/llm/byok_provider.py +130 -0
  210. core/api/services/ingest/llm/classification_context.py +301 -0
  211. core/api/services/ingest/llm/config_store.py +246 -0
  212. core/api/services/ingest/llm/factory.py +24 -0
  213. core/api/services/ingest/llm/kg_enricher.py +306 -0
  214. core/api/services/ingest/llm/local_gateway.py +821 -0
  215. core/api/services/ingest/llm/local_vllm.py +23 -0
  216. core/api/services/ingest/llm/openai_nano.py +349 -0
  217. core/api/services/ingest/lock_advisory.py +57 -0
  218. core/api/services/ingest/parser_router.py +1756 -0
  219. core/api/services/ingest/parsers/__init__.py +1 -0
  220. core/api/services/ingest/parsers/docling_parser.py +142 -0
  221. core/api/services/ingest/parsers/docparse_gateway.py +178 -0
  222. core/api/services/ingest/parsers/docx_parser.py +127 -0
  223. core/api/services/ingest/parsers/folder_unpacker.py +85 -0
  224. core/api/services/ingest/parsers/gateway_aux.py +147 -0
  225. core/api/services/ingest/parsers/image_parser.py +251 -0
  226. core/api/services/ingest/parsers/internal_markdown.py +89 -0
  227. core/api/services/ingest/parsers/ocr_gateway.py +117 -0
  228. core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
  229. core/api/services/ingest/parsers/pdf_types.py +13 -0
  230. core/api/services/ingest/parsers/transcript_parser.py +445 -0
  231. core/api/services/ingest/parsers/vision_gateway.py +186 -0
  232. core/api/services/ingest/parsers/xlsx_parser.py +91 -0
  233. core/api/services/ingest/parsers/zip_unpacker.py +126 -0
  234. core/api/services/ingest/preflight.py +393 -0
  235. core/api/services/ingest/retry_voyage.py +88 -0
  236. core/api/services/ingest/routing_policy.py +307 -0
  237. core/api/services/ingest/serializers/__init__.py +1 -0
  238. core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
  239. core/api/services/ingest/skip_log.py +74 -0
  240. core/api/services/ingest/watcher.py +637 -0
  241. core/api/services/kg/__init__.py +0 -0
  242. core/api/services/kg/audit.py +49 -0
  243. core/api/services/kg/hybrid_search.py +691 -0
  244. core/api/services/kg/lens.py +339 -0
  245. core/api/services/kg/pr_impact.py +770 -0
  246. core/api/services/kg/queries.py +152 -0
  247. core/api/services/kg/ranking.py +89 -0
  248. core/api/services/kg/rrf.py +143 -0
  249. core/api/services/kg_watcher_control.py +161 -0
  250. core/api/services/local_llm/__init__.py +19 -0
  251. core/api/services/local_llm/async_client.py +385 -0
  252. core/api/services/local_llm/client.py +173 -0
  253. core/api/services/local_llm/url_validator.py +44 -0
  254. core/api/services/metrics_collector.py +646 -0
  255. core/api/services/metrics_providers.py +65 -0
  256. core/api/services/model_registry.py +266 -0
  257. core/api/services/model_router.py +137 -0
  258. core/api/services/n8n_client.py +77 -0
  259. core/api/services/newsletter_llm_gateway.py +66 -0
  260. core/api/services/notification_service.py +134 -0
  261. core/api/services/openai_responses.py +55 -0
  262. core/api/services/opencode_metrics.py +375 -0
  263. core/api/services/opencode_sessions.py +173 -0
  264. core/api/services/pii_redactor.py +138 -0
  265. core/api/services/pr_impact_pipeline/__init__.py +21 -0
  266. core/api/services/pr_impact_pipeline/differ.py +421 -0
  267. core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
  268. core/api/services/pr_impact_pipeline/gc.py +93 -0
  269. core/api/services/pr_impact_pipeline/languages.py +192 -0
  270. core/api/services/pr_impact_pipeline/parser.py +178 -0
  271. core/api/services/pr_impact_pipeline/writer.py +394 -0
  272. core/api/services/pr_service.py +1393 -0
  273. core/api/services/project_paths.py +70 -0
  274. core/api/services/project_status_updates.py +265 -0
  275. core/api/services/providers.py +276 -0
  276. core/api/services/push_service.py +170 -0
  277. core/api/services/reminder_service.py +89 -0
  278. core/api/services/runas.py +41 -0
  279. core/api/services/salience_service.py +69 -0
  280. core/api/services/security_collector.py +281 -0
  281. core/api/services/session_catalog.py +385 -0
  282. core/api/services/session_metrics_service.py +301 -0
  283. core/api/services/session_ops.py +272 -0
  284. core/api/services/session_state.py +173 -0
  285. core/api/services/share_links.py +222 -0
  286. core/api/services/task_transitions.py +146 -0
  287. core/api/services/terminal_metrics.py +462 -0
  288. core/api/services/terminal_metrics_dump.py +203 -0
  289. core/api/services/tmux.py +1205 -0
  290. core/api/services/webhook_service.py +422 -0
  291. core/api/services/workspace_sync.py +164 -0
  292. core/api/templates/__init__.py +1 -0
  293. core/api/templates/markdown_share.py +164 -0
  294. core/api/terminal.py +1031 -0
  295. core/api/tests/__init__.py +0 -0
  296. core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
  297. core/api/tests/test_audit_permissions.py +133 -0
  298. core/api/tests/test_backfill_session_conversations.py +90 -0
  299. core/api/tests/test_backfill_working_seconds_msg.py +129 -0
  300. core/api/tests/test_claude_metrics.py +326 -0
  301. core/api/tests/test_codex_metrics.py +189 -0
  302. core/api/tests/test_finder_paths.py +74 -0
  303. core/api/tests/test_git_ops_merge.py +155 -0
  304. core/api/tests/test_learnings_check_search.py +81 -0
  305. core/api/tests/test_metrics_providers.py +133 -0
  306. core/api/tests/test_migration_087.py +164 -0
  307. core/api/tests/test_migration_088.py +94 -0
  308. core/api/tests/test_migration_089.py +116 -0
  309. core/api/tests/test_openai_responses.py +24 -0
  310. core/api/tests/test_opencode_metrics.py +740 -0
  311. core/api/tests/test_opencode_sessions.py +321 -0
  312. core/api/tests/test_pr_workflow_e2e.py +457 -0
  313. core/api/tests/test_projects_handoffs.py +31 -0
  314. core/api/tests/test_providers.py +138 -0
  315. core/api/tests/test_safety_bridge.py +347 -0
  316. core/api/tests/test_session_catalog.py +142 -0
  317. core/api/tests/test_session_conversations.py +512 -0
  318. core/api/tests/test_session_metrics_service.py +270 -0
  319. core/api/tests/test_session_resume_paths.py +548 -0
  320. core/api/tests/test_session_theme_mode_migration.py +56 -0
  321. core/api/tests/test_sessions_rbac.py +131 -0
  322. core/api/tests/test_share_edit.py +398 -0
  323. core/api/tests/test_share_repo.py +200 -0
  324. core/api/tests/test_terminal_session_manager.py +98 -0
  325. core/api/tests/test_terminal_upload.py +34 -0
  326. core/api/tests/test_tmux.py +272 -0
  327. core/api/tests/test_workspace_sync.py +186 -0
  328. core/api/tests/test_ws_ticket_in_memory.py +73 -0
  329. core/api/use_cases/__init__.py +11 -0
  330. core/api/use_cases/_context.py +89 -0
  331. core/api/use_cases/_errors.py +62 -0
  332. core/api/use_cases/_roles.py +16 -0
  333. core/api/use_cases/audit.py +171 -0
  334. core/api/use_cases/brain.py +1232 -0
  335. core/api/use_cases/costs.py +249 -0
  336. core/api/use_cases/graph.py +1153 -0
  337. core/api/use_cases/handoffs.py +506 -0
  338. core/api/use_cases/ingest_triage.py +1229 -0
  339. core/api/use_cases/learnings.py +538 -0
  340. core/api/use_cases/projects.py +705 -0
  341. core/api/use_cases/pull_requests.py +415 -0
  342. core/api/use_cases/search.py +926 -0
  343. core/api/use_cases/tasks.py +1495 -0
  344. core/api/visibility.py +141 -0
  345. core/cli/__init__.py +5 -0
  346. core/cli/_index_source.py +632 -0
  347. core/cli/_runtime_ctx.py +160 -0
  348. core/cli/_transmute.py +241 -0
  349. core/cli/marvis_doctor.py +704 -0
  350. core/cli/marvis_feedback.py +396 -0
  351. core/cli/marvis_governance.py +315 -0
  352. core/cli/marvis_hooks.py +515 -0
  353. core/cli/marvis_init.py +757 -0
  354. core/cli/marvis_mcp.py +401 -0
  355. core/cli/marvis_runtime.py +855 -0
  356. core/cli/marvis_telemetry.py +228 -0
  357. core/scripts/_drift_check.py +716 -0
  358. core/scripts/_frontmatter.py +66 -0
  359. core/scripts/_graph_writer.py +189 -0
  360. core/scripts/ast_parser.py +1553 -0
  361. core/scripts/install_hooks/__init__.py +1 -0
  362. core/scripts/install_hooks/_config.sh +109 -0
  363. core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
  364. core/scripts/install_hooks/block-db-direct-write.sh +23 -0
  365. core/scripts/install_hooks/block-push-no-task.sh +23 -0
  366. core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
  367. core/scripts/install_hooks/block-subtree-push.sh +23 -0
  368. core/scripts/install_hooks/config.json +53 -0
  369. core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
  370. core/scripts/install_hooks/enforce-worktree.sh +23 -0
  371. core/scripts/install_hooks/quality-gate.sh +170 -0
  372. core/scripts/install_hooks/safety_bridge.py +968 -0
  373. core/scripts/install_hooks/secret-scan.sh +23 -0
  374. core/scripts/migrate_spike_node_ids.py +122 -0
  375. core/scripts/populate_artifacts.py +2198 -0
  376. core/scripts/populate_cross_project.py +2457 -0
  377. core/scripts/populate_inbox_nodes.py +357 -0
  378. core/scripts/populate_pr_impact.py +267 -0
  379. core/scripts/populate_project_nodes.py +603 -0
  380. core/scripts/populate_touch_counter.py +337 -0
  381. core/scripts/reparse_failed.py +57 -0
  382. core/scripts/safety_bridge.py +968 -0
  383. core/telemetry/__init__.py +9 -0
  384. core/telemetry/client.py +405 -0
  385. core/telemetry/schema.py +122 -0
  386. core/wizard/__init__.py +65 -0
  387. core/wizard/byok_vault.py +147 -0
  388. core/wizard/defaults.py +58 -0
  389. core/wizard/state.py +117 -0
  390. core/wizard/steps.py +70 -0
  391. core/wizard/validation.py +136 -0
  392. marvisx_cli-0.1.0.dist-info/METADATA +201 -0
  393. marvisx_cli-0.1.0.dist-info/RECORD +587 -0
  394. marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
  395. marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
  396. marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
  397. marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
  398. migrations/001_initial.sql +33 -0
  399. migrations/002_tasks.sql +30 -0
  400. migrations/003_session_management.sql +7 -0
  401. migrations/004_projects_comments.sql +65 -0
  402. migrations/005_session_intelligence.sql +15 -0
  403. migrations/006_settings.sql +12 -0
  404. migrations/007_task_scoring.sql +12 -0
  405. migrations/008_cost_tracking.sql +31 -0
  406. migrations/009_session_card_metrics.sql +3 -0
  407. migrations/010_monitoring.sql +55 -0
  408. migrations/012_agent_api.sql +21 -0
  409. migrations/013_session_complete.sql +8 -0
  410. migrations/015_pull_requests.sql +43 -0
  411. migrations/015_pull_requests_down.sql +5 -0
  412. migrations/016_users_raci.sql +116 -0
  413. migrations/017_task_cost_entries.sql +87 -0
  414. migrations/018_agents.sql +73 -0
  415. migrations/018_agents_down.sql +13 -0
  416. migrations/019_review_feedback.sql +18 -0
  417. migrations/020_pr_commit_sha.sql +4 -0
  418. migrations/021_webhook_events.sql +18 -0
  419. migrations/022_devx_agent_managed.sql +11 -0
  420. migrations/022_devx_agent_managed_down.sql +6 -0
  421. migrations/023_devx_p1_gate.sql +7 -0
  422. migrations/023_devx_p1_gate_down.sql +3 -0
  423. migrations/024_chat_messages.sql +16 -0
  424. migrations/024_pr_conversation_id.sql +8 -0
  425. migrations/024_task_indexes.sql +21 -0
  426. migrations/024_task_indexes_down.sql +7 -0
  427. migrations/025_audit_log.sql +17 -0
  428. migrations/026_agent_tokens.sql +20 -0
  429. migrations/027_teams_auth_phase_b.sql +35 -0
  430. migrations/028_learnings.sql +23 -0
  431. migrations/029_team_roles.sql +14 -0
  432. migrations/030_finder_pins.sql +10 -0
  433. migrations/031_pr_deploy_status.sql +9 -0
  434. migrations/032_task_reminders.sql +7 -0
  435. migrations/033_events_retry_count.sql +6 -0
  436. migrations/033_session_owner.sql +9 -0
  437. migrations/034_notifications.sql +38 -0
  438. migrations/035_shared_links.sql +15 -0
  439. migrations/036_session_index_upgrade.sql +29 -0
  440. migrations/037_pr_approval.sql +15 -0
  441. migrations/038_pr_submitted_by.sql +6 -0
  442. migrations/039_push_subscriptions.sql +17 -0
  443. migrations/040_semantic_search.sql +16 -0
  444. migrations/041_workspaces.sql +63 -0
  445. migrations/042_oidc_providers.sql +24 -0
  446. migrations/043_ci_checks.sql +31 -0
  447. migrations/044_agent_metrics.sql +30 -0
  448. migrations/045_documents_doc_type.sql +5 -0
  449. migrations/046_salience.sql +13 -0
  450. migrations/047_seed_missing_agents.sql +6 -0
  451. migrations/048_fix_agent_paths_roles.sql +5 -0
  452. migrations/049_agent_role_and_learnings_schema.sql +3 -0
  453. migrations/050_session_provider.sql +2 -0
  454. migrations/051_session_launch_profile.sql +4 -0
  455. migrations/052_session_theme_mode.sql +2 -0
  456. migrations/052_task_kind.sql +4 -0
  457. migrations/053_inbox_items.sql +31 -0
  458. migrations/054_inbox_triage_contract.sql +30 -0
  459. migrations/055_inbox_topic_treatment.sql +12 -0
  460. migrations/056_inbox_treatment_read_save.sql +57 -0
  461. migrations/057_session_theme_mode_backfill.sql +4 -0
  462. migrations/058_inbox_item_status_lifecycle.sql +13 -0
  463. migrations/059_inbox_tldr_and_source_scores.sql +18 -0
  464. migrations/060_newsletter.sql +16 -0
  465. migrations/061_inbox_redesign.sql +69 -0
  466. migrations/062_fix_inbox_sources_backfill.sql +37 -0
  467. migrations/063_task_completion_mode.sql +23 -0
  468. migrations/064_judge_mode_setting.sql +4 -0
  469. migrations/065_knowledge_graph_spike.sql +40 -0
  470. migrations/066_digest_ranking_inputs.sql +10 -0
  471. migrations/066_kg_artifact_nodes.sql +129 -0
  472. migrations/067_inbox_digest_selections.sql +28 -0
  473. migrations/067_kg_temporal.sql +53 -0
  474. migrations/068_inbox_digest_app_settings.sql +9 -0
  475. migrations/068_kg_touch_counter.sql +52 -0
  476. migrations/069_kg_doc_types.sql +117 -0
  477. migrations/070_digest_ranking_inputs_recovery.sql +3 -0
  478. migrations/071_inbox_digest_selections_recovery.sql +3 -0
  479. migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
  480. migrations/073_kg_cross_project.sql +216 -0
  481. migrations/073_kg_cross_project_down.sql +77 -0
  482. migrations/074_kg_infra_types.sql +208 -0
  483. migrations/074_kg_infra_types_down.sql +80 -0
  484. migrations/075_kg_file_state_recovery.sql +35 -0
  485. migrations/075_kg_file_state_recovery_down.sql +5 -0
  486. migrations/076_kg_watcher_state.sql +33 -0
  487. migrations/076_kg_watcher_state_down.sql +3 -0
  488. migrations/077_kg_doc_types_extend.sql +226 -0
  489. migrations/077_kg_doc_types_extend_down.sql +80 -0
  490. migrations/078_kg_fts5.sql +102 -0
  491. migrations/078_kg_fts5_down.sql +14 -0
  492. migrations/079_kg_missing_indexes.sql +31 -0
  493. migrations/079_kg_missing_indexes_down.sql +10 -0
  494. migrations/080_kg_fts5_extended.sql +232 -0
  495. migrations/080_kg_fts5_extended_down.sql +25 -0
  496. migrations/081_kg_lens_indexes.sql +9 -0
  497. migrations/081_kg_lens_indexes_down.sql +3 -0
  498. migrations/082_kg_pins.sql +26 -0
  499. migrations/082_kg_pins_down.sql +14 -0
  500. migrations/083_kg_graph_nodes_degree.sql +20 -0
  501. migrations/083_kg_graph_nodes_degree_down.sql +15 -0
  502. migrations/084_drop_legacy_scheduler_tables.sql +58 -0
  503. migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
  504. migrations/085_kg_edge_resolves_to.sql +142 -0
  505. migrations/085_kg_edge_resolves_to_down.sql +66 -0
  506. migrations/086_project_status_updates_feed.sql +20 -0
  507. migrations/086_project_status_updates_feed_down.sql +36 -0
  508. migrations/087_session_metrics_dual.sql +50 -0
  509. migrations/087_session_metrics_dual_down.sql +21 -0
  510. migrations/088_rename_context_pct_legacy.sql +23 -0
  511. migrations/088_rename_context_pct_legacy_down.sql +8 -0
  512. migrations/089_session_metrics_equivalent_cost.sql +26 -0
  513. migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
  514. migrations/090_kg_inbox_node_type.sql +26 -0
  515. migrations/090_kg_inbox_node_type_down.sql +20 -0
  516. migrations/091_kg_inbox_node_type_check.sql +265 -0
  517. migrations/091_kg_inbox_node_type_check_down.sql +129 -0
  518. migrations/092_sessions_activity_state_ts.sql +29 -0
  519. migrations/092_sessions_activity_state_ts_down.sql +14 -0
  520. migrations/093_sessions_activity_state_column.sql +29 -0
  521. migrations/093_sessions_activity_state_column_down.sql +10 -0
  522. migrations/094_ingest_pending.sql +55 -0
  523. migrations/094_ingest_pending_down.sql +15 -0
  524. migrations/095_kg_intent_first.sql +77 -0
  525. migrations/095_kg_intent_first_down.sql +25 -0
  526. migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
  527. migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
  528. migrations/097_ingest_change_history.sql +37 -0
  529. migrations/097_ingest_change_history_down.sql +13 -0
  530. migrations/098_kg_node_type_business.sql +254 -0
  531. migrations/098_kg_node_type_business_down.sql +195 -0
  532. migrations/099_kg_edges_restore_weight.sql +58 -0
  533. migrations/099_kg_edges_restore_weight_down.sql +12 -0
  534. migrations/100_kg_enriched_at.sql +25 -0
  535. migrations/100_kg_enriched_at_down.sql +12 -0
  536. migrations/101_local_llm_shadow_comparisons.sql +66 -0
  537. migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
  538. migrations/102_promote_llm_costs.sql +69 -0
  539. migrations/102_promote_llm_costs_down.sql +19 -0
  540. migrations/103_ingest_skipped_log.sql +46 -0
  541. migrations/103_ingest_skipped_log_down.sql +15 -0
  542. migrations/120_docs_governance.sql +50 -0
  543. migrations/120_docs_governance_down.sql +11 -0
  544. migrations/121_notification_event_fk_cleanup.sql +21 -0
  545. migrations/121_notification_event_fk_cleanup_down.sql +10 -0
  546. migrations/122_docs_drift_history.sql +34 -0
  547. migrations/122_docs_drift_history_down.sql +15 -0
  548. migrations/123_ingest_parser_waiting_status.sql +69 -0
  549. migrations/123_ingest_parser_waiting_status_down.sql +69 -0
  550. migrations/124_heypocket_recordings.sql +63 -0
  551. migrations/124_heypocket_recordings_down.sql +13 -0
  552. migrations/125_kg_node_type_record.sql +219 -0
  553. migrations/125_kg_node_type_record_down.sql +205 -0
  554. migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
  555. migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
  556. migrations/127_brain_v1_substrate.sql +200 -0
  557. migrations/127_brain_v1_substrate_down.sql +32 -0
  558. migrations/128_brain_drift_signals.sql +157 -0
  559. migrations/128_brain_drift_signals_down.sql +23 -0
  560. migrations/129_brain_memory_operations.sql +232 -0
  561. migrations/129_brain_memory_operations_down.sql +27 -0
  562. migrations/130_brain_findings.sql +258 -0
  563. migrations/130_brain_findings_down.sql +29 -0
  564. migrations/132_kg_pr_modifies.sql +242 -0
  565. migrations/132_kg_pr_modifies_down.sql +99 -0
  566. migrations/133_brain_v1_2_direction_schema.sql +476 -0
  567. migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
  568. migrations/134_brain_journal_narrative_polished.sql +8 -0
  569. migrations/134_brain_journal_narrative_polished_down.sql +6 -0
  570. migrations/135_kg_edges_provider.sql +21 -0
  571. migrations/136_documents_fts.sql +56 -0
  572. migrations/137_promote_llm_costs.sql +59 -0
  573. migrations/137_promote_llm_costs_down.sql +19 -0
  574. migrations/138_ingest_api_keys.sql +39 -0
  575. migrations/138_ingest_api_keys_down.sql +8 -0
  576. migrations/139_ingest_pending_ingress.sql +91 -0
  577. migrations/139_ingest_pending_ingress_down.sql +73 -0
  578. migrations/140_ingest_idempotency_quota.sql +45 -0
  579. migrations/140_ingest_idempotency_quota_down.sql +9 -0
  580. migrations/141_ingest_pending_metadata.sql +16 -0
  581. migrations/141_ingest_pending_metadata_down.sql +7 -0
  582. migrations/142_llm_function_config.sql +36 -0
  583. migrations/142_llm_function_config_down.sql +8 -0
  584. migrations/143_kg_code_embeddings.sql +25 -0
  585. migrations/143_kg_code_embeddings_down.sql +5 -0
  586. migrations/__init__.py +4 -0
  587. projects/_template/project.yaml +46 -0
@@ -0,0 +1,621 @@
1
+ # v1.4.0 - 2026-04-15 - _merge_branch_sync: fetch + fast-forward local target before merge (fixes recurring non-fast-forward push failures)
2
+ # v1.3.0 - 2026-03-12 - Centralize ALLOWED_REPO_PARENTS → api/config.py
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import uuid
12
+ from pathlib import Path
13
+ from typing import TypedDict
14
+
15
+ import pygit2
16
+
17
+ pygit2.option(pygit2.GIT_OPT_SET_OWNER_VALIDATION, 0)
18
+
19
+ # git/chown operations run as a configurable service-account user (deployments
20
+ # where the API process is not the repo owner). Single-user / self-hosted: plain
21
+ # `git` with no chown. Same source of truth used by pr_service / routers.projects.
22
+ from core.api.services.runas import GIT_CMD as _GIT_CMD, chown_to_runas, runas_user
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # --- Exceptions ---
27
+
28
+
29
+ class GitOpsError(Exception):
30
+ pass
31
+
32
+
33
+ class NotAGitRepoError(GitOpsError):
34
+ pass
35
+
36
+
37
+ class WorktreeConflictError(GitOpsError):
38
+ pass
39
+
40
+
41
+ class MergeConflictError(GitOpsError):
42
+ def __init__(self, conflicting_files: list[str]):
43
+ self.conflicting_files = conflicting_files
44
+ super().__init__(f"Merge conflict in {len(conflicting_files)} files")
45
+
46
+
47
+ # --- Constants ---
48
+
49
+ TASK_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{4,64}$")
50
+
51
+ from core.api.config import ALLOWED_REPO_PARENTS # centralized in config.py
52
+
53
+ WORKTREES_BASE = Path(os.environ.get("MARVIS_WORKTREES_BASE", str(Path.home() / "dev")))
54
+ MERGE_WORKTREE_PREFIX = ".merge-"
55
+
56
+
57
+ # --- Validation ---
58
+
59
+
60
+ def _validate_repo_path(repo_path: str) -> Path:
61
+ """Validate repo_path against allowlist and ensure .git exists."""
62
+ repo = Path(repo_path).resolve()
63
+ if not any(repo.is_relative_to(p) for p in ALLOWED_REPO_PARENTS):
64
+ raise NotAGitRepoError(f"Repo path not in allowlist: {repo}")
65
+ if not (repo / ".git").exists():
66
+ raise NotAGitRepoError(f"No .git directory at {repo}")
67
+ return repo
68
+
69
+
70
+ def _validate_task_id(task_id: str) -> str:
71
+ """Validate task_id format to prevent path traversal."""
72
+ if not TASK_ID_PATTERN.match(task_id):
73
+ raise ValueError(f"Invalid task_id format: {task_id!r}")
74
+ return task_id
75
+
76
+
77
+ # --- TypedDicts ---
78
+
79
+
80
+ class WorktreeInfo(TypedDict):
81
+ worktree_path: str
82
+ branch_name: str
83
+ already_existed: bool
84
+
85
+
86
+ class PrDiffResult(TypedDict):
87
+ stats: dict # {additions, deletions, files_changed}
88
+ unified_diff: str
89
+ is_empty: bool
90
+
91
+
92
+ class MergeResult(TypedDict):
93
+ merged: bool
94
+ already_merged: bool
95
+ commit_sha: str
96
+ # Empty string when already_merged=True or when primary sha was unreadable.
97
+ # Consumers use this to sync the primary workspace working tree post-merge.
98
+ pre_merge_sha: str
99
+
100
+
101
+ # --- Sync implementations (run via asyncio.to_thread) ---
102
+
103
+
104
+ def _create_worktree_sync(
105
+ repo_path: str, task_id: str, base_branch: str = "main"
106
+ ) -> WorktreeInfo:
107
+ task_id = _validate_task_id(task_id)
108
+ repo_dir = _validate_repo_path(repo_path)
109
+
110
+ branch_name = f"feat/task-{task_id}"
111
+ worktree_path = WORKTREES_BASE / f"task-{task_id}"
112
+
113
+ # Idempotent: if worktree already exists, return it
114
+ if worktree_path.exists() and (worktree_path / ".git").exists():
115
+ return WorktreeInfo(
116
+ worktree_path=str(worktree_path),
117
+ branch_name=branch_name,
118
+ already_existed=True,
119
+ )
120
+
121
+ repo = pygit2.Repository(str(repo_dir))
122
+
123
+ # Resolve base branch to commit
124
+ try:
125
+ base_ref = repo.lookup_branch(base_branch)
126
+ if base_ref is None:
127
+ raise NotAGitRepoError(f"Branch '{base_branch}' not found")
128
+ base_commit = base_ref.peel(pygit2.Commit)
129
+ except Exception as exc:
130
+ raise NotAGitRepoError(
131
+ f"Cannot resolve base branch '{base_branch}': {exc}"
132
+ ) from exc
133
+
134
+ # Create or reuse the feature branch
135
+ existing = repo.lookup_branch(branch_name)
136
+ if existing is None:
137
+ repo.branches.local.create(branch_name, base_commit)
138
+
139
+ # Ensure worktrees base exists
140
+ WORKTREES_BASE.mkdir(parents=True, exist_ok=True)
141
+
142
+ # Create worktree via git CLI (more reliable than pygit2 for worktree management)
143
+ result = subprocess.run(
144
+ [*_GIT_CMD, "worktree", "add", str(worktree_path), branch_name],
145
+ cwd=str(repo_dir),
146
+ capture_output=True,
147
+ text=True,
148
+ )
149
+ if result.returncode != 0:
150
+ # Might already be checked out — try to recover
151
+ if "already checked out" in result.stderr:
152
+ raise WorktreeConflictError(
153
+ f"Branch '{branch_name}' already checked out: {result.stderr.strip()}"
154
+ )
155
+ raise GitOpsError(f"git worktree add failed: {result.stderr.strip()}")
156
+
157
+ # When the API runs as a service account distinct from the repo owner, the
158
+ # new worktree and .git metadata are owned by that account. Chown them back
159
+ # to the run-as user so agents (running as that user) can commit inside the
160
+ # worktree. No-op in single-user / self-hosted deployments.
161
+ if runas_user():
162
+ # The worktree directory itself
163
+ chown_to_runas(worktree_path)
164
+ # .git/refs and .git/logs in the main repo — git worktree add may create
165
+ # new ref/log entries owned by the service account
166
+ git_dir = repo_dir / ".git"
167
+ for subdir in ("refs", "logs"):
168
+ target = git_dir / subdir
169
+ if target.exists():
170
+ chown_to_runas(target)
171
+ # Also .git/worktrees/ metadata for this worktree
172
+ wt_meta = git_dir / "worktrees" / f"task-{task_id}"
173
+ if wt_meta.exists():
174
+ chown_to_runas(wt_meta)
175
+
176
+ logger.info(
177
+ "Created worktree for task %s at %s (branch %s)",
178
+ task_id,
179
+ worktree_path,
180
+ branch_name,
181
+ )
182
+
183
+ return WorktreeInfo(
184
+ worktree_path=str(worktree_path),
185
+ branch_name=branch_name,
186
+ already_existed=False,
187
+ )
188
+
189
+
190
+ def _get_pr_diff_sync(
191
+ repo_path: str, branch: str, target: str = "main"
192
+ ) -> PrDiffResult:
193
+ repo_dir = _validate_repo_path(repo_path)
194
+ repo = pygit2.Repository(str(repo_dir))
195
+
196
+ # Resolve branch and target commits
197
+ branch_ref = repo.lookup_branch(branch)
198
+ target_ref = repo.lookup_branch(target)
199
+ if branch_ref is None:
200
+ raise GitOpsError(f"Branch '{branch}' not found")
201
+ if target_ref is None:
202
+ raise GitOpsError(f"Target branch '{target}' not found")
203
+
204
+ branch_commit = branch_ref.peel(pygit2.Commit)
205
+ target_commit = target_ref.peel(pygit2.Commit)
206
+
207
+ # Merge-base diff: shows only commits on the branch, not target advances
208
+ merge_base_oid = repo.merge_base(branch_commit.id, target_commit.id)
209
+ if merge_base_oid is None:
210
+ raise GitOpsError(f"No common ancestor between '{branch}' and '{target}'")
211
+
212
+ merge_base = repo.get(merge_base_oid)
213
+ diff = repo.diff(merge_base.peel(pygit2.Tree), branch_commit.peel(pygit2.Tree))
214
+ diff.find_similar()
215
+
216
+ unified = diff.patch or ""
217
+ stats = diff.stats
218
+
219
+ return PrDiffResult(
220
+ stats={
221
+ "additions": stats.insertions,
222
+ "deletions": stats.deletions,
223
+ "files_changed": stats.files_changed,
224
+ },
225
+ unified_diff=unified,
226
+ is_empty=stats.files_changed == 0,
227
+ )
228
+
229
+
230
+ def _build_temp_merge_worktree_path(target: str) -> Path:
231
+ safe_target = re.sub(r"[^a-zA-Z0-9._-]+", "-", target).strip("-") or "target"
232
+ return (
233
+ WORKTREES_BASE / f"{MERGE_WORKTREE_PREFIX}{safe_target}-{uuid.uuid4().hex[:8]}"
234
+ )
235
+
236
+
237
+ def _cleanup_temp_merge_worktree(repo_dir: Path, worktree_path: Path) -> None:
238
+ subprocess.run(
239
+ [*_GIT_CMD, "worktree", "remove", "--force", str(worktree_path)],
240
+ cwd=str(repo_dir),
241
+ capture_output=True,
242
+ text=True,
243
+ )
244
+ if worktree_path.exists():
245
+ shutil.rmtree(worktree_path, ignore_errors=True)
246
+
247
+
248
+ def _merge_branch_sync(
249
+ repo_path: str, branch: str, target: str = "main"
250
+ ) -> MergeResult:
251
+ repo_dir = _validate_repo_path(repo_path)
252
+
253
+ # Sync local target with origin before merging. The long-running API workspace
254
+ # can lag behind origin/main after external pushes, causing non-fast-forward
255
+ # push failures. Fail-clean if local has diverged (human rebase required).
256
+ # Use explicit refspec so the remote-tracking ref is updated regardless of
257
+ # remote.origin.fetch config (plain "fetch origin main" only touches FETCH_HEAD).
258
+ fetch = subprocess.run(
259
+ [
260
+ *_GIT_CMD,
261
+ "fetch",
262
+ "origin",
263
+ f"+refs/heads/{target}:refs/remotes/origin/{target}",
264
+ ],
265
+ cwd=str(repo_dir),
266
+ capture_output=True,
267
+ text=True,
268
+ )
269
+ if fetch.returncode != 0:
270
+ raise GitOpsError(f"git fetch origin {target} failed: {fetch.stderr.strip()}")
271
+
272
+ remote_ref = f"origin/{target}"
273
+ remote_sha_res = subprocess.run(
274
+ [*_GIT_CMD, "rev-parse", remote_ref],
275
+ cwd=str(repo_dir),
276
+ capture_output=True,
277
+ text=True,
278
+ )
279
+ if remote_sha_res.returncode != 0:
280
+ raise GitOpsError(
281
+ f"Cannot resolve '{remote_ref}': {remote_sha_res.stderr.strip()}"
282
+ )
283
+ remote_sha = remote_sha_res.stdout.strip()
284
+
285
+ local_sha_res = subprocess.run(
286
+ [*_GIT_CMD, "rev-parse", target],
287
+ cwd=str(repo_dir),
288
+ capture_output=True,
289
+ text=True,
290
+ )
291
+ if local_sha_res.returncode != 0:
292
+ raise GitOpsError(
293
+ f"Cannot resolve local '{target}': {local_sha_res.stderr.strip()}"
294
+ )
295
+ local_sha = local_sha_res.stdout.strip()
296
+
297
+ if local_sha != remote_sha:
298
+ local_behind = subprocess.run(
299
+ [*_GIT_CMD, "merge-base", "--is-ancestor", local_sha, remote_sha],
300
+ cwd=str(repo_dir),
301
+ capture_output=True,
302
+ )
303
+ if local_behind.returncode == 0:
304
+ # Local is strict ancestor of origin → fast-forward local to origin.
305
+ ff_res = subprocess.run(
306
+ [*_GIT_CMD, "update-ref", f"refs/heads/{target}", remote_sha, local_sha],
307
+ cwd=str(repo_dir),
308
+ capture_output=True,
309
+ text=True,
310
+ )
311
+ if ff_res.returncode != 0:
312
+ raise GitOpsError(
313
+ f"Fast-forward of local '{target}' to '{remote_ref}' failed: "
314
+ f"{ff_res.stderr.strip()}"
315
+ )
316
+ logger.info(
317
+ "Fast-forwarded local '%s' from %s to %s (origin advanced)",
318
+ target,
319
+ local_sha[:8],
320
+ remote_sha[:8],
321
+ )
322
+ else:
323
+ local_ahead = subprocess.run(
324
+ [*_GIT_CMD, "merge-base", "--is-ancestor", remote_sha, local_sha],
325
+ cwd=str(repo_dir),
326
+ capture_output=True,
327
+ )
328
+ if local_ahead.returncode != 0:
329
+ raise GitOpsError(
330
+ f"Local '{target}' ({local_sha[:8]}) has diverged from "
331
+ f"'{remote_ref}' ({remote_sha[:8]}). Human rebase required: "
332
+ f"cd {repo_dir} && git fetch origin && git rebase origin/{target}"
333
+ )
334
+ # Local is ahead of origin → merge will push forward, no action needed.
335
+
336
+ current_target = subprocess.run(
337
+ [*_GIT_CMD, "rev-parse", target],
338
+ cwd=str(repo_dir),
339
+ capture_output=True,
340
+ text=True,
341
+ )
342
+ if current_target.returncode != 0:
343
+ raise GitOpsError(
344
+ f"Cannot resolve current '{target}': {current_target.stderr.strip()}"
345
+ )
346
+ original_target_sha = current_target.stdout.strip()
347
+
348
+ # First check if already merged
349
+ result = subprocess.run(
350
+ [*_GIT_CMD, "merge-base", "--is-ancestor", branch, target],
351
+ cwd=str(repo_dir),
352
+ capture_output=True,
353
+ )
354
+ if result.returncode == 0:
355
+ # Branch is already ancestor of target — already merged
356
+ sha = subprocess.run(
357
+ [*_GIT_CMD, "rev-parse", target],
358
+ cwd=str(repo_dir),
359
+ capture_output=True,
360
+ text=True,
361
+ )
362
+ return MergeResult(
363
+ merged=True,
364
+ already_merged=True,
365
+ commit_sha=sha.stdout.strip(),
366
+ pre_merge_sha="",
367
+ )
368
+
369
+ temp_worktree = _build_temp_merge_worktree_path(target)
370
+ add = subprocess.run(
371
+ [*_GIT_CMD, "worktree", "add", "--detach", str(temp_worktree), target],
372
+ cwd=str(repo_dir),
373
+ capture_output=True,
374
+ text=True,
375
+ )
376
+ if add.returncode != 0:
377
+ raise GitOpsError(f"git worktree add failed: {add.stderr.strip()}")
378
+
379
+ try:
380
+ ff_only = subprocess.run(
381
+ [*_GIT_CMD, "merge-base", "--is-ancestor", target, branch],
382
+ cwd=str(repo_dir),
383
+ capture_output=True,
384
+ )
385
+ merge_args = ["merge", "--no-edit", "--no-verify", branch]
386
+ if ff_only.returncode == 0:
387
+ merge_args = ["merge", "--ff-only", "--no-verify", branch]
388
+
389
+ # Merge in an isolated detached worktree so dirty files in the primary repo
390
+ # cannot block the review -> merge gate.
391
+ merge = subprocess.run(
392
+ [*_GIT_CMD, *merge_args],
393
+ cwd=str(temp_worktree),
394
+ capture_output=True,
395
+ text=True,
396
+ )
397
+
398
+ if merge.returncode != 0:
399
+ if "CONFLICT" in merge.stdout or "CONFLICT" in merge.stderr:
400
+ status = subprocess.run(
401
+ [*_GIT_CMD, "diff", "--name-only", "--diff-filter=U"],
402
+ cwd=str(temp_worktree),
403
+ capture_output=True,
404
+ text=True,
405
+ )
406
+ conflicting = [f for f in status.stdout.strip().split("\n") if f]
407
+ subprocess.run(
408
+ [*_GIT_CMD, "merge", "--abort"],
409
+ cwd=str(temp_worktree),
410
+ capture_output=True,
411
+ )
412
+ raise MergeConflictError(conflicting)
413
+ error = merge.stderr.strip() or merge.stdout.strip()
414
+ raise GitOpsError(f"Merge failed: {error}")
415
+
416
+ sha = subprocess.run(
417
+ [*_GIT_CMD, "rev-parse", "HEAD"],
418
+ cwd=str(temp_worktree),
419
+ capture_output=True,
420
+ text=True,
421
+ )
422
+ commit_sha = sha.stdout.strip()
423
+
424
+ update_ref = subprocess.run(
425
+ [*_GIT_CMD, "update-ref", f"refs/heads/{target}", commit_sha],
426
+ cwd=str(repo_dir),
427
+ capture_output=True,
428
+ text=True,
429
+ )
430
+ if update_ref.returncode != 0:
431
+ raise GitOpsError(
432
+ f"Failed to update '{target}': {update_ref.stderr.strip()}"
433
+ )
434
+
435
+ # Chown .git/refs and .git/logs after updating refs — new refs/logs may
436
+ # be created when the API runs as a service account distinct from the
437
+ # repo owner. No-op in single-user / self-hosted deployments.
438
+ if runas_user():
439
+ git_dir = repo_dir / ".git"
440
+ for subdir in ("refs", "logs"):
441
+ target_dir = git_dir / subdir
442
+ if target_dir.exists():
443
+ chown_to_runas(target_dir)
444
+
445
+ push = subprocess.run(
446
+ [*_GIT_CMD, "push", "origin", f"{commit_sha}:refs/heads/{target}"],
447
+ cwd=str(repo_dir),
448
+ capture_output=True,
449
+ text=True,
450
+ )
451
+ if push.returncode != 0:
452
+ rollback = subprocess.run(
453
+ [*_GIT_CMD, "update-ref", f"refs/heads/{target}", original_target_sha],
454
+ cwd=str(repo_dir),
455
+ capture_output=True,
456
+ text=True,
457
+ )
458
+ if rollback.returncode != 0:
459
+ logger.error(
460
+ "Failed to roll back local %s after push failure: %s",
461
+ target,
462
+ rollback.stderr.strip(),
463
+ )
464
+ logger.warning(
465
+ "Push to origin/%s failed after merge: %s",
466
+ target,
467
+ push.stderr.strip(),
468
+ )
469
+ raise GitOpsError(
470
+ f"Push to origin/{target} failed after merge: {push.stderr.strip() or push.stdout.strip()}"
471
+ )
472
+ else:
473
+ logger.info("Pushed '%s' to origin after merge", target)
474
+
475
+ logger.info(
476
+ "Merged branch '%s' into '%s' at %s using isolated worktree %s",
477
+ branch,
478
+ target,
479
+ commit_sha,
480
+ temp_worktree,
481
+ )
482
+ return MergeResult(
483
+ merged=True,
484
+ already_merged=False,
485
+ commit_sha=commit_sha,
486
+ pre_merge_sha=original_target_sha,
487
+ )
488
+ finally:
489
+ _cleanup_temp_merge_worktree(repo_dir, temp_worktree)
490
+
491
+
492
+ def _remove_worktree_sync(repo_path: str, worktree_path: str, branch: str) -> None:
493
+ repo_dir = _validate_repo_path(repo_path)
494
+ wt_path = Path(worktree_path).resolve()
495
+
496
+ # Security: verify worktree is inside expected directory
497
+ if not wt_path.is_relative_to(WORKTREES_BASE.resolve()):
498
+ raise GitOpsError(f"Worktree path not in allowed base: {wt_path}")
499
+
500
+ # Remove worktree via git CLI
501
+ subprocess.run(
502
+ [*_GIT_CMD, "worktree", "remove", "--force", str(wt_path)],
503
+ cwd=str(repo_dir),
504
+ capture_output=True,
505
+ )
506
+
507
+ # Remove directory if it still exists
508
+ if wt_path.exists():
509
+ shutil.rmtree(str(wt_path))
510
+
511
+ # Prune stale worktree references
512
+ subprocess.run(
513
+ [*_GIT_CMD, "worktree", "prune"],
514
+ cwd=str(repo_dir),
515
+ capture_output=True,
516
+ )
517
+
518
+ # Delete local branch (force, may not be fully merged if PR was closed)
519
+ subprocess.run(
520
+ [*_GIT_CMD, "branch", "-D", branch],
521
+ cwd=str(repo_dir),
522
+ capture_output=True,
523
+ )
524
+
525
+ # Delete remote branch (best effort, non-blocking)
526
+ subprocess.run(
527
+ [*_GIT_CMD, "push", "origin", "--delete", branch],
528
+ cwd=str(repo_dir),
529
+ capture_output=True,
530
+ )
531
+
532
+ logger.info("Removed worktree %s and branch %s (local + remote)", wt_path, branch)
533
+
534
+
535
+ def _revert_commit_sync(repo_path: str, commit_sha: str, branch_name: str) -> dict:
536
+ """Create a git revert commit on a new branch. Returns new commit SHA."""
537
+ repo_dir = _validate_repo_path(repo_path)
538
+
539
+ # Create new branch from main
540
+ branch_result = subprocess.run(
541
+ [*_GIT_CMD, "checkout", "-b", branch_name, "main"],
542
+ cwd=str(repo_dir),
543
+ capture_output=True,
544
+ text=True,
545
+ )
546
+ if branch_result.returncode != 0:
547
+ raise GitOpsError(
548
+ f"Failed to create revert branch: {branch_result.stderr.strip()}"
549
+ )
550
+
551
+ try:
552
+ # Revert the commit (no-edit: auto commit message)
553
+ revert_result = subprocess.run(
554
+ [*_GIT_CMD, "revert", "--no-edit", commit_sha],
555
+ cwd=str(repo_dir),
556
+ capture_output=True,
557
+ text=True,
558
+ )
559
+ if revert_result.returncode != 0:
560
+ raise GitOpsError(f"git revert failed: {revert_result.stderr.strip()}")
561
+
562
+ # Get new commit SHA
563
+ sha_result = subprocess.run(
564
+ [*_GIT_CMD, "rev-parse", "HEAD"],
565
+ cwd=str(repo_dir),
566
+ capture_output=True,
567
+ text=True,
568
+ )
569
+ new_sha = sha_result.stdout.strip()
570
+ finally:
571
+ # Always return to main
572
+ subprocess.run(
573
+ [*_GIT_CMD, "checkout", "main"], cwd=str(repo_dir), capture_output=True
574
+ )
575
+
576
+ logger.info(
577
+ "Created revert commit %s on branch %s (reverts %s)",
578
+ new_sha,
579
+ branch_name,
580
+ commit_sha,
581
+ )
582
+ return {"commit_sha": new_sha, "branch": branch_name}
583
+
584
+
585
+ # --- Async wrappers ---
586
+
587
+
588
+ async def create_worktree_async(
589
+ repo_path: str, task_id: str, base_branch: str = "main"
590
+ ) -> WorktreeInfo:
591
+ return await asyncio.to_thread(
592
+ _create_worktree_sync, repo_path, task_id, base_branch
593
+ )
594
+
595
+
596
+ async def get_pr_diff_async(
597
+ repo_path: str, branch: str, target: str = "main"
598
+ ) -> PrDiffResult:
599
+ return await asyncio.to_thread(_get_pr_diff_sync, repo_path, branch, target)
600
+
601
+
602
+ async def merge_branch_async(
603
+ repo_path: str, branch: str, target: str = "main"
604
+ ) -> MergeResult:
605
+ return await asyncio.to_thread(_merge_branch_sync, repo_path, branch, target)
606
+
607
+
608
+ async def remove_worktree_async(
609
+ repo_path: str, worktree_path: str, branch: str
610
+ ) -> None:
611
+ return await asyncio.to_thread(
612
+ _remove_worktree_sync, repo_path, worktree_path, branch
613
+ )
614
+
615
+
616
+ async def revert_commit_async(
617
+ repo_path: str, commit_sha: str, branch_name: str
618
+ ) -> dict:
619
+ return await asyncio.to_thread(
620
+ _revert_commit_sync, repo_path, commit_sha, branch_name
621
+ )