abstra 3.23.12__py3-none-any.whl → 3.24.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 (262) hide show
  1. abstra/cli.py +8 -3
  2. {abstra-3.23.12.dist-info → abstra-3.24.0.dist-info}/METADATA +2 -1
  3. {abstra-3.23.12.dist-info → abstra-3.24.0.dist-info}/RECORD +197 -187
  4. abstra_internals/consts/filepaths.py +1 -1
  5. abstra_internals/contracts_generated.py +665 -655
  6. abstra_internals/controllers/git.py +305 -0
  7. abstra_internals/controllers/main.py +10 -6
  8. abstra_internals/environment.py +6 -0
  9. abstra_internals/interface/cli/deploy.py +1 -1
  10. abstra_internals/interface/sdk/ai.py +1 -0
  11. abstra_internals/repositories/git/__init__.py +38 -0
  12. abstra_internals/repositories/git/dulwich.py +1353 -0
  13. abstra_internals/repositories/git/git_test.py +1572 -0
  14. abstra_internals/repositories/git/native.py +578 -0
  15. abstra_internals/repositories/git/types.py +267 -0
  16. abstra_internals/repositories/linter/rules/env_in_bundle.py +5 -5
  17. abstra_internals/repositories/linter/rules/env_in_bundle_test.py +6 -6
  18. abstra_internals/repositories/linter/rules/venv_in_bundle.py +9 -16
  19. abstra_internals/server/blueprints/editor.py +4 -0
  20. abstra_internals/server/routes/git.py +190 -0
  21. abstra_internals/server/routes/workspace.py +1 -1
  22. abstra_internals/services/file_watcher.py +32 -13
  23. abstra_internals/services/fs.py +4 -4
  24. abstra_internals/services/fs_test.py +4 -6
  25. abstra_internals/templates/__init__.py +0 -11
  26. abstra_statics/dist/assets/AbstraButton.vue_vue_type_script_setup_true_lang.eb8ccb64.js +2 -0
  27. abstra_statics/dist/assets/AbstraLogo.vue_vue_type_script_setup_true_lang.9f98292e.js +2 -0
  28. abstra_statics/dist/assets/ApiKeys.e975c4f3.js +2 -0
  29. abstra_statics/dist/assets/App.f62faff6.js +2 -0
  30. abstra_statics/dist/assets/App.vue_vue_type_style_index_0_lang.5aa45ac1.js +2 -0
  31. abstra_statics/dist/assets/BaseLayout.1ec2c96d.js +2 -0
  32. abstra_statics/dist/assets/{Billing.461db3ef.js → Billing.60adc9fa.js} +2 -2
  33. abstra_statics/dist/assets/{Breadcrumb.27110ec4.js → Breadcrumb.26dec5f7.js} +2 -2
  34. abstra_statics/dist/assets/{Builds.4eecd717.js → Builds.f86210bc.js} +2 -2
  35. abstra_statics/dist/assets/{Card.1d1a9fb7.js → Card.6cfffb80.js} +2 -2
  36. abstra_statics/dist/assets/{CircularLoading.fc66331b.js → CircularLoading.a13d6a76.js} +2 -2
  37. abstra_statics/dist/assets/{CloseCircleOutlined.0110bbe2.js → CloseCircleOutlined.30bc25a8.js} +2 -2
  38. abstra_statics/dist/assets/{ConnectorsView.82e74ae4.css → ConnectorsView.33c5380f.css} +1 -1
  39. abstra_statics/dist/assets/ConnectorsView.eb4c769f.js +2 -0
  40. abstra_statics/dist/assets/ConsoleOmniChat.vue_vue_type_script_setup_true_lang.8d2e4672.js +2 -0
  41. abstra_statics/dist/assets/ContentLayout.d03fee5b.js +2 -0
  42. abstra_statics/dist/assets/{CrudView.c16e2f81.js → CrudView.c0824225.js} +2 -2
  43. abstra_statics/dist/assets/DocsButton.vue_vue_type_script_setup_true_lang.3668aee4.js +2 -0
  44. abstra_statics/dist/assets/{EditorLogin.00a3b3d9.js → EditorLogin.46db248f.js} +2 -2
  45. abstra_statics/dist/assets/{EditorsView.f7b2843c.js → EditorsView.e72621fa.js} +2 -2
  46. abstra_statics/dist/assets/EnvVars.f9f9d61f.js +2 -0
  47. abstra_statics/dist/assets/{Error.466dbb94.js → Error.864f05b3.js} +2 -2
  48. abstra_statics/dist/assets/ExclamationCircleOutlined.a489b996.js +2 -0
  49. abstra_statics/dist/assets/{Files.104385dd.js → Files.afe615e1.js} +2 -2
  50. abstra_statics/dist/assets/{Form.8077681f.js → Form.556d0de2.js} +2 -2
  51. abstra_statics/dist/assets/{FormRunner.055e2c45.js → FormRunner.7f56a8c6.js} +2 -2
  52. abstra_statics/dist/assets/{Home.0ef22910.js → Home.287d17f8.js} +2 -2
  53. abstra_statics/dist/assets/Home.5b7e9c23.js +2 -0
  54. abstra_statics/dist/assets/{Live.e1261458.js → Live.50bacfea.js} +2 -2
  55. abstra_statics/dist/assets/LoadingContainer.ebace8de.js +2 -0
  56. abstra_statics/dist/assets/{LoadingOutlined.cd84d9c9.js → LoadingOutlined.9e949112.js} +2 -2
  57. abstra_statics/dist/assets/{Login.041361ea.js → Login.536a3067.js} +2 -2
  58. abstra_statics/dist/assets/Login.bec408c9.js +2 -0
  59. abstra_statics/dist/assets/{Login.vue_vue_type_script_setup_true_lang.c2eb444c.js → Login.vue_vue_type_script_setup_true_lang.1c3f108d.js} +2 -2
  60. abstra_statics/dist/assets/Logo.82d6ab70.js +2 -0
  61. abstra_statics/dist/assets/{Logs.f76cde12.js → Logs.f6135084.js} +2 -2
  62. abstra_statics/dist/assets/{LogsController.addd81bf.js → LogsController.6b666816.js} +2 -2
  63. abstra_statics/dist/assets/Main.77b115f8.js +2 -0
  64. abstra_statics/dist/assets/{MockForm.c9441864.js → MockForm.deda9355.js} +2 -2
  65. abstra_statics/dist/assets/{Navbar.07ba9452.js → Navbar.4a6f2b09.js} +2 -2
  66. abstra_statics/dist/assets/NewEditor.5f84de86.css +1 -0
  67. abstra_statics/dist/assets/NewEditor.e558e47d.js +8 -0
  68. abstra_statics/dist/assets/OidcLoginCallback.7f514b45.js +2 -0
  69. abstra_statics/dist/assets/OidcLogoutCallback.038813a1.js +2 -0
  70. abstra_statics/dist/assets/OmniChat.05ba8d8a.css +1 -0
  71. abstra_statics/dist/assets/OmniChat.60d98deb.js +6 -0
  72. abstra_statics/dist/assets/{OnboardingView.fbc4b6fe.js → OnboardingView.9413ee50.js} +2 -2
  73. abstra_statics/dist/assets/{Organization.bc495099.js → Organization.7203cc0b.js} +2 -2
  74. abstra_statics/dist/assets/{Organizations.b630803f.js → Organizations.91220ca0.js} +2 -2
  75. abstra_statics/dist/assets/{PhArrowCounterClockwise.vue.156bcd89.js → PhArrowCounterClockwise.vue.8090d021.js} +2 -2
  76. abstra_statics/dist/assets/{PhArrowSquareOut.vue.d0c95a06.js → PhArrowSquareOut.vue.26582195.js} +2 -2
  77. abstra_statics/dist/assets/{PhBookBookmark.vue.42e49494.js → PhBookBookmark.vue.5b7ab079.js} +2 -2
  78. abstra_statics/dist/assets/{PhChats.vue.54d692e4.js → PhChats.vue.b5df7174.js} +2 -2
  79. abstra_statics/dist/assets/{PhClockCounterClockwise.vue.d47d66ba.js → PhClockCounterClockwise.vue.812311ad.js} +2 -2
  80. abstra_statics/dist/assets/{PhCopy.vue.a0d9b0ec.js → PhCopy.vue.59b0f1b4.js} +2 -2
  81. abstra_statics/dist/assets/PhCopySimple.vue.d41d9160.js +2 -0
  82. abstra_statics/dist/assets/{PhCube.vue.498c014d.js → PhCube.vue.63ae7d32.js} +2 -2
  83. abstra_statics/dist/assets/PhDatabase.vue.edfcb96b.js +2 -0
  84. abstra_statics/dist/assets/{PhDotsThreeVertical.vue.0ea03d82.js → PhDotsThreeVertical.vue.ab4580a5.js} +2 -2
  85. abstra_statics/dist/assets/PhDownloadSimple.vue.c2eaaad1.js +2 -0
  86. abstra_statics/dist/assets/{PhFolderPlus.vue.0c210f8d.js → PhFolderPlus.vue.05ba4a5c.js} +2 -2
  87. abstra_statics/dist/assets/{PhGear.vue.86c3014a.js → PhGear.vue.0e4a6135.js} +2 -2
  88. abstra_statics/dist/assets/{PhKey.vue.72ce23d3.js → PhKey.vue.b2c184d1.js} +2 -2
  89. abstra_statics/dist/assets/{PhPencil.vue.80ed4b2e.js → PhPencil.vue.2f2fe576.js} +2 -2
  90. abstra_statics/dist/assets/{PhPencilSimple.vue.0046d784.js → PhPencilSimple.vue.cc8620ae.js} +2 -2
  91. abstra_statics/dist/assets/{PhRocket.vue.f3302a7e.js → PhRocket.vue.e397203c.js} +2 -2
  92. abstra_statics/dist/assets/{PhSignOut.vue.c9150da4.js → PhSignOut.vue.a271f14b.js} +2 -2
  93. abstra_statics/dist/assets/{PhSparkle.vue.84ea95fc.js → PhSparkle.vue.726defa2.js} +2 -2
  94. abstra_statics/dist/assets/{PhUserList.vue.bce47902.js → PhUserList.vue.6a29f16d.js} +2 -2
  95. abstra_statics/dist/assets/{PhUsersThree.vue.0a84dfa4.js → PhUsersThree.vue.275d13ab.js} +2 -2
  96. abstra_statics/dist/assets/PhWarningCircle.vue.3b1aca1b.js +2 -0
  97. abstra_statics/dist/assets/{PhWebhooksLogo.vue.a704632d.js → PhWebhooksLogo.vue.5e772aac.js} +2 -2
  98. abstra_statics/dist/assets/{PlayerConfigProvider.26b585a1.js → PlayerConfigProvider.e90a2b41.js} +2 -2
  99. abstra_statics/dist/assets/{PlayerNavbar.0fa6f760.js → PlayerNavbar.11ec1844.js} +2 -2
  100. abstra_statics/dist/assets/Project.c16740fb.js +2 -0
  101. abstra_statics/dist/assets/ProjectLogin.e7a6f444.js +2 -0
  102. abstra_statics/dist/assets/{ProjectSettings.2bf7e6c8.js → ProjectSettings.52c19693.js} +2 -2
  103. abstra_statics/dist/assets/{ProjectsView.ed31b921.js → ProjectsView.22fa7a8e.js} +2 -2
  104. abstra_statics/dist/assets/{SaveButton.fdf70b31.js → SaveButton.719393d2.js} +2 -2
  105. abstra_statics/dist/assets/{ScrollArea.vue_vue_type_script_setup_true_lang.3bbea426.js → ScrollArea.vue_vue_type_script_setup_true_lang.d4028954.js} +2 -2
  106. abstra_statics/dist/assets/{Sidebar.e69f49bd.css → Sidebar.29baeab0.css} +1 -1
  107. abstra_statics/dist/assets/{Sidebar.781afa0a.js → Sidebar.5cb8e04e.js} +2 -2
  108. abstra_statics/dist/assets/Sql.23d80bad.js +5 -0
  109. abstra_statics/dist/assets/Sql.90e6e2ba.css +1 -0
  110. abstra_statics/dist/assets/Steps.8e5d201a.js +2 -0
  111. abstra_statics/dist/assets/TableCard.529112b9.css +1 -0
  112. abstra_statics/dist/assets/TableCard.59f95f8f.js +2 -0
  113. abstra_statics/dist/assets/TableEditor.5853a363.css +1 -0
  114. abstra_statics/dist/assets/TableEditor.8539f984.js +2 -0
  115. abstra_statics/dist/assets/Tables.44d953f7.js +2 -0
  116. abstra_statics/dist/assets/TablesDiagram.8e47383c.js +15 -0
  117. abstra_statics/dist/assets/TablesDiagram.a588e7ff.css +1 -0
  118. abstra_statics/dist/assets/{TablesTabs.vue_vue_type_script_setup_true_lang.63aa07d0.js → TablesTabs.vue_vue_type_script_setup_true_lang.6866fb32.js} +2 -2
  119. abstra_statics/dist/assets/{Tasks.ee450480.js → Tasks.09551b19.js} +2 -2
  120. abstra_statics/dist/assets/{UploadOutlined.e3072945.js → UploadOutlined.eab75eb0.js} +2 -2
  121. abstra_statics/dist/assets/View.5fd7ddf0.js +2 -0
  122. abstra_statics/dist/assets/{View.vue_vue_type_script_setup_true_lang.ee5d447b.js → View.vue_vue_type_script_setup_true_lang.a904f400.js} +2 -2
  123. abstra_statics/dist/assets/{Watermark.891eee9f.js → Watermark.ab3d818f.js} +2 -2
  124. abstra_statics/dist/assets/{WebEditor.7409cd48.js → WebEditor.d6ec6392.js} +2 -2
  125. abstra_statics/dist/assets/WidgetPreview.86b31dec.js +2 -0
  126. abstra_statics/dist/assets/ant-design.a865486e.js +2 -0
  127. abstra_statics/dist/assets/apiKey.72f497ca.js +2 -0
  128. abstra_statics/dist/assets/asyncComputed.cf5282fc.js +2 -0
  129. abstra_statics/dist/assets/{build.8fa1a961.js → build.df2d55cc.js} +2 -2
  130. abstra_statics/dist/assets/{colorHelpers.71d6d61d.js → colorHelpers.37d9932b.js} +2 -2
  131. abstra_statics/dist/assets/console.2bf7f04d.js +17 -0
  132. abstra_statics/dist/assets/constants.7d38ec8b.js +2 -0
  133. abstra_statics/dist/assets/{contracts.generated.c4057ed0.js → contracts.generated.590b1102.js} +2 -2
  134. abstra_statics/dist/assets/{cssMode.4c65b876.js → cssMode.7133c7cb.js} +2 -2
  135. abstra_statics/dist/assets/datetime.8de2ff28.js +2 -0
  136. abstra_statics/dist/assets/dayjs.f18bbbca.js +2 -0
  137. abstra_statics/dist/assets/editor.3a4714e3.js +2 -0
  138. abstra_statics/dist/assets/editor.main.9c635b9a.js +2 -0
  139. abstra_statics/dist/assets/fetch.89fd5b7b.js +2 -0
  140. abstra_statics/dist/assets/folder.d8e23009.js +2 -0
  141. abstra_statics/dist/assets/{freemarker2.1d872d48.js → freemarker2.6698d1ea.js} +2 -2
  142. abstra_statics/dist/assets/{handlebars.41fc6db8.js → handlebars.a6c42dc0.js} +2 -2
  143. abstra_statics/dist/assets/{html.967e3c6d.js → html.493a5410.js} +3 -3
  144. abstra_statics/dist/assets/{htmlMode.d38ab72a.js → htmlMode.a28b2fca.js} +2 -2
  145. abstra_statics/dist/assets/{index.51dbb698.js → index.2af3391c.js} +2 -2
  146. abstra_statics/dist/assets/{index.9021ba5d.js → index.4176fe88.js} +2 -2
  147. abstra_statics/dist/assets/{index.b762f5e8.js → index.4d20c159.js} +2 -2
  148. abstra_statics/dist/assets/{index.8e871bae.js → index.5d6b1e62.js} +2 -2
  149. abstra_statics/dist/assets/index.79ce3bf1.js +2 -0
  150. abstra_statics/dist/assets/{index.03d222dd.js → index.c34a405a.js} +2 -2
  151. abstra_statics/dist/assets/index.fb182bd1.js +2 -0
  152. abstra_statics/dist/assets/{index.5f3f38ed.js → index.fb49354b.js} +2 -2
  153. abstra_statics/dist/assets/{javascript.0935bea2.js → javascript.3a36cf17.js} +3 -3
  154. abstra_statics/dist/assets/{jsonMode.2860b71c.js → jsonMode.bead6ac8.js} +2 -2
  155. abstra_statics/dist/assets/{jwt-decode.esm.7f2ef0df.js → jwt-decode.esm.d4517a10.js} +8 -8
  156. abstra_statics/dist/assets/linters.2f3141cb.js +2 -0
  157. abstra_statics/dist/assets/{liquid.1bae5f6b.js → liquid.0c337fae.js} +3 -3
  158. abstra_statics/dist/assets/{member.b7ac8000.js → member.48d6f2cd.js} +2 -2
  159. abstra_statics/dist/assets/{metadata.39f9b9ba.js → metadata.c3aed6e1.js} +2 -2
  160. abstra_statics/dist/assets/{omniChatStore.3431c026.js → omniChatStore.c53bcca2.js} +3 -3
  161. abstra_statics/dist/assets/{organization.a877b653.js → organization.928c9bef.js} +2 -2
  162. abstra_statics/dist/assets/player.d3aeafc5.js +2 -0
  163. abstra_statics/dist/assets/{plotly.min.d3f75723.js → plotly.min.7225d3a0.js} +2 -2
  164. abstra_statics/dist/assets/polling.82ee6b45.js +2 -0
  165. abstra_statics/dist/assets/{project.d22a89ee.js → project.619b7244.js} +2 -2
  166. abstra_statics/dist/assets/{python.4c1a1300.js → python.05764499.js} +3 -3
  167. abstra_statics/dist/assets/{razor.ba8bdb33.js → razor.81a45581.js} +3 -3
  168. abstra_statics/dist/assets/record.7f43486c.js +2 -0
  169. abstra_statics/dist/assets/{redirect.ce3c0f65.js → redirect.f028a879.js} +2 -2
  170. abstra_statics/dist/assets/repository.9534db4b.js +2 -0
  171. abstra_statics/dist/assets/repository.c15239ce.js +2 -0
  172. abstra_statics/dist/assets/router.262190ec.js +2 -0
  173. abstra_statics/dist/assets/router.424f7da9.js +18 -0
  174. abstra_statics/dist/assets/{string.2ed1cde3.js → string.0acf5572.js} +2 -2
  175. abstra_statics/dist/assets/{tables.be1c51f8.js → tables.1f68ec62.js} +2 -2
  176. abstra_statics/dist/assets/{tasksController.bf12e264.js → tasksController.371896de.js} +2 -2
  177. abstra_statics/dist/assets/{toggleHighContrast.c4e1b24d.js → toggleHighContrast.0d0e5662.js} +7 -7
  178. abstra_statics/dist/assets/{tsMode.da264aae.js → tsMode.6eadbf06.js} +2 -2
  179. abstra_statics/dist/assets/{typescript.db5d7517.js → typescript.1670e287.js} +3 -3
  180. abstra_statics/dist/assets/url.e8732f77.js +2 -0
  181. abstra_statics/dist/assets/{useCodebaseEvents.42573b00.js → useCodebaseEvents.53dec1f2.js} +2 -2
  182. abstra_statics/dist/assets/useTables.4f034cf8.js +2 -0
  183. abstra_statics/dist/assets/userStore.31024da3.js +2 -0
  184. abstra_statics/dist/assets/uuid.bde15ce7.js +2 -0
  185. abstra_statics/dist/assets/vue-flow-background.818c7852.js +2 -0
  186. abstra_statics/dist/assets/vue-flow-core.1180ec83.js +22 -0
  187. abstra_statics/dist/assets/{vue-quill.esm-bundler.37119951.js → vue-quill.esm-bundler.c4f04985.js} +2 -2
  188. abstra_statics/dist/assets/{workspaceStore.50ef2df1.js → workspaceStore.6244d03d.js} +2 -2
  189. abstra_statics/dist/assets/{xml.94b88503.js → xml.f2867af8.js} +3 -3
  190. abstra_statics/dist/assets/{yaml.73b7d5ce.js → yaml.5427bb1b.js} +2 -2
  191. abstra_statics/dist/console.html +14 -15
  192. abstra_statics/dist/editor.html +13 -13
  193. abstra_statics/dist/player.html +9 -9
  194. tests/e2e/test_crud_files.py +1 -0
  195. abstra_internals/templates/abstraignore +0 -8
  196. abstra_statics/dist/assets/AbstraButton.vue_vue_type_script_setup_true_lang.441fcdfd.js +0 -2
  197. abstra_statics/dist/assets/AbstraLogo.vue_vue_type_script_setup_true_lang.3f03a3ef.js +0 -2
  198. abstra_statics/dist/assets/ApiKeys.cb561e62.js +0 -2
  199. abstra_statics/dist/assets/App.dc82115f.js +0 -2
  200. abstra_statics/dist/assets/App.vue_vue_type_style_index_0_lang.3640ec1c.js +0 -2
  201. abstra_statics/dist/assets/BaseLayout.c63dfc2d.js +0 -2
  202. abstra_statics/dist/assets/ConnectorsView.b428e487.js +0 -2
  203. abstra_statics/dist/assets/ConsoleOmniChat.vue_vue_type_script_setup_true_lang.506f28c6.js +0 -2
  204. abstra_statics/dist/assets/ContentLayout.228d2328.js +0 -2
  205. abstra_statics/dist/assets/DocsButton.vue_vue_type_script_setup_true_lang.61381525.js +0 -2
  206. abstra_statics/dist/assets/EnvVars.242b97c2.js +0 -2
  207. abstra_statics/dist/assets/ExclamationCircleOutlined.6d10f60b.js +0 -2
  208. abstra_statics/dist/assets/Home.80c7e349.js +0 -2
  209. abstra_statics/dist/assets/LoadingContainer.6e2b63e4.js +0 -2
  210. abstra_statics/dist/assets/Login.409f4a11.js +0 -2
  211. abstra_statics/dist/assets/Logo.2de02c4a.js +0 -2
  212. abstra_statics/dist/assets/Main.4370ed68.js +0 -2
  213. abstra_statics/dist/assets/NewEditor.769f4459.js +0 -8
  214. abstra_statics/dist/assets/NewEditor.d6e41a05.css +0 -1
  215. abstra_statics/dist/assets/OidcLoginCallback.a89857fe.js +0 -2
  216. abstra_statics/dist/assets/OidcLogoutCallback.d151c695.js +0 -2
  217. abstra_statics/dist/assets/OmniChat.0f64dfec.css +0 -1
  218. abstra_statics/dist/assets/OmniChat.d015bfa8.js +0 -6
  219. abstra_statics/dist/assets/PhCopySimple.vue.43c74ebe.js +0 -2
  220. abstra_statics/dist/assets/PhDownloadSimple.vue.c92aeaff.js +0 -2
  221. abstra_statics/dist/assets/PhPencilSimpleLine.vue.34633dfa.js +0 -2
  222. abstra_statics/dist/assets/Project.c03610d3.js +0 -2
  223. abstra_statics/dist/assets/ProjectLogin.934271a6.js +0 -2
  224. abstra_statics/dist/assets/Sql.3cdc910a.css +0 -1
  225. abstra_statics/dist/assets/Sql.b6aa38ca.js +0 -5
  226. abstra_statics/dist/assets/Steps.7c7e4a4a.js +0 -2
  227. abstra_statics/dist/assets/TableEditor.1e680eaf.css +0 -1
  228. abstra_statics/dist/assets/TableEditor.dc1b4a2d.js +0 -2
  229. abstra_statics/dist/assets/Tables.de30953b.js +0 -2
  230. abstra_statics/dist/assets/TablesDiagram.1ec45dd9.css +0 -1
  231. abstra_statics/dist/assets/TablesDiagram.97d6a43f.js +0 -15
  232. abstra_statics/dist/assets/View.617ad8d8.js +0 -2
  233. abstra_statics/dist/assets/WidgetPreview.99f14714.js +0 -2
  234. abstra_statics/dist/assets/ant-design.4952c8fb.js +0 -2
  235. abstra_statics/dist/assets/apiKey.864dc66b.js +0 -2
  236. abstra_statics/dist/assets/asyncComputed.25309626.js +0 -2
  237. abstra_statics/dist/assets/console.3d4702c3.js +0 -25
  238. abstra_statics/dist/assets/constants.56e8988f.js +0 -2
  239. abstra_statics/dist/assets/datetime.e5660676.js +0 -2
  240. abstra_statics/dist/assets/dayjs.c54f8edb.js +0 -2
  241. abstra_statics/dist/assets/editor.6d0baf6f.js +0 -2
  242. abstra_statics/dist/assets/editor.main.65812c73.js +0 -2
  243. abstra_statics/dist/assets/fetch.5136a62d.js +0 -2
  244. abstra_statics/dist/assets/folder.d7d65e5b.js +0 -2
  245. abstra_statics/dist/assets/index.23283fbb.js +0 -2
  246. abstra_statics/dist/assets/index.4b93c8ad.js +0 -2
  247. abstra_statics/dist/assets/index.58e29274.js +0 -2
  248. abstra_statics/dist/assets/linters.640d6098.js +0 -2
  249. abstra_statics/dist/assets/player.30593e18.js +0 -2
  250. abstra_statics/dist/assets/polling.d1c934c7.js +0 -2
  251. abstra_statics/dist/assets/record.4ffc477c.js +0 -2
  252. abstra_statics/dist/assets/repository.360feb8f.js +0 -2
  253. abstra_statics/dist/assets/repository.ab3036a9.js +0 -2
  254. abstra_statics/dist/assets/router.7f571832.js +0 -2
  255. abstra_statics/dist/assets/router.8fd5b2ad.js +0 -10
  256. abstra_statics/dist/assets/url.8583a595.js +0 -2
  257. abstra_statics/dist/assets/userStore.73b89fbb.js +0 -2
  258. abstra_statics/dist/assets/uuid.dadede91.js +0 -2
  259. abstra_statics/dist/assets/vue-flow-background.d2772d9a.js +0 -22
  260. {abstra-3.23.12.dist-info → abstra-3.24.0.dist-info}/WHEEL +0 -0
  261. {abstra-3.23.12.dist-info → abstra-3.24.0.dist-info}/entry_points.txt +0 -0
  262. {abstra-3.23.12.dist-info → abstra-3.24.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1353 @@
1
+ import datetime
2
+ import os
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, List, Optional, Tuple
5
+ from urllib.parse import urlparse
6
+
7
+ import urllib3
8
+
9
+ from abstra_internals.environment import REMOTE_NAME
10
+ from abstra_internals.logger import AbstraLogger
11
+
12
+ from .types import GitCommit, GitFileChange, GitRepositoryInterface, GitStatus
13
+
14
+ try:
15
+ from dulwich import porcelain
16
+ from dulwich.client import HttpGitClient
17
+ from dulwich.errors import NotGitRepository
18
+ from dulwich.index import build_index_from_tree
19
+ from dulwich.objects import Commit
20
+ from dulwich.porcelain import reset
21
+ from dulwich.repo import Repo
22
+ from dulwich.stash import Stash
23
+
24
+ DULWICH_AVAILABLE = True
25
+ except ImportError:
26
+ DULWICH_AVAILABLE = False
27
+ if TYPE_CHECKING:
28
+ from dulwich import porcelain
29
+ from dulwich.client import HttpGitClient
30
+ from dulwich.errors import NotGitRepository
31
+ from dulwich.index import build_index_from_tree
32
+ from dulwich.objects import Commit
33
+ from dulwich.porcelain import reset
34
+ from dulwich.repo import Repo
35
+ from dulwich.stash import Stash
36
+
37
+
38
+ class DulwichGitRepository(GitRepositoryInterface):
39
+ """Repository for Git operations using Dulwich library"""
40
+
41
+ def __init__(self, working_directory: Path):
42
+ super().__init__(working_directory)
43
+
44
+ self._repo: Optional["Repo"] = None # type: ignore
45
+ self._try_load_repo()
46
+
47
+ def _try_load_repo(self):
48
+ """Try to load existing repository"""
49
+ try:
50
+ if self.is_git_repository():
51
+ self._repo = Repo(str(self.working_directory)) # type: ignore
52
+ except Exception:
53
+ self._repo = None
54
+
55
+ def configure_git_user(self, fallback_email: str, fallback_name: str):
56
+ """Ensure git user is configured for commits (required in CI environments)"""
57
+ if not self._repo:
58
+ return
59
+ try:
60
+ config = self._repo.get_config()
61
+ try:
62
+ config.get((b"user",), b"name")
63
+ except KeyError:
64
+ config.set((b"user",), b"name", fallback_name.encode())
65
+ try:
66
+ config.get((b"user",), b"email")
67
+ except KeyError:
68
+ config.set((b"user",), b"email", fallback_email.encode())
69
+ config.write_to_path()
70
+ except Exception:
71
+ pass
72
+
73
+ def is_git_available(self) -> bool:
74
+ """Check if dulwich is available"""
75
+ return DULWICH_AVAILABLE
76
+
77
+ def find_git_root(self, start_path: Optional[Path] = None) -> Optional[Path]:
78
+ """Find the root directory of the git repository"""
79
+ if start_path is None:
80
+ start_path = self.working_directory
81
+
82
+ if start_path is None:
83
+ start_path = Path.cwd()
84
+
85
+ current = start_path.resolve()
86
+ while current != current.parent:
87
+ if (current / ".git").exists():
88
+ return current
89
+ current = current.parent
90
+ return None
91
+
92
+ def is_git_repository(self) -> bool:
93
+ """Check if current directory is a git repository"""
94
+ if not self.is_git_available():
95
+ return False
96
+
97
+ try:
98
+ pass # Ensure try block has at least one except clause
99
+ Repo(str(self.working_directory)) # type: ignore
100
+ return True
101
+ except (NotGitRepository, Exception): # type: ignore
102
+ return False
103
+
104
+ def init_repository(self) -> bool:
105
+ """Initialize a new git repository in the working directory"""
106
+ if not self.is_git_available():
107
+ return False
108
+
109
+ try:
110
+ self._repo = porcelain.init(str(self.working_directory))
111
+
112
+ # Set HEAD to point to main branch instead of master BEFORE any commits
113
+ # This ensures the first commit goes to main branch
114
+ self._repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/main")
115
+
116
+ add_success = True
117
+ try:
118
+ porcelain.add(
119
+ str(self.working_directory), [str(self.working_directory)]
120
+ )
121
+ except Exception:
122
+ add_success = False
123
+
124
+ commit_success = False
125
+
126
+ if add_success:
127
+ try:
128
+ status = porcelain.status(str(self.working_directory))
129
+ has_staged_files = (
130
+ len(status.staged["add"]) > 0
131
+ or len(status.staged["modify"]) > 0
132
+ or len(status.staged["delete"]) > 0
133
+ )
134
+
135
+ if has_staged_files:
136
+ # Commit staged files
137
+ porcelain.commit(str(self.working_directory), b"First commit")
138
+ commit_success = True
139
+ except Exception:
140
+ pass
141
+
142
+ # If we haven't created a commit yet, create an empty one
143
+ if not commit_success:
144
+ try:
145
+ # Create empty commit using porcelain - this should work even with no files
146
+ # We need to ensure the index exists first
147
+ index_path = os.path.join(
148
+ str(self.working_directory), ".git", "index"
149
+ )
150
+ if not os.path.exists(index_path):
151
+ # Touch the index file to create it
152
+ Path(index_path).touch()
153
+
154
+ porcelain.commit(str(self.working_directory), b"First commit")
155
+ commit_success = True
156
+ except Exception:
157
+ # Last resort: create commit manually
158
+ try:
159
+ import time
160
+
161
+ from dulwich.objects import Commit as DulwichCommit
162
+ from dulwich.objects import Tree
163
+
164
+ # Create empty tree
165
+ empty_tree = Tree()
166
+ tree_id = self._repo.object_store.add_object(empty_tree)
167
+
168
+ # Create commit object
169
+ commit = DulwichCommit()
170
+ commit.tree = tree_id
171
+ commit.author = commit.committer = (
172
+ b"Abstra <no-reply@abstra.app>"
173
+ )
174
+ commit.commit_time = commit.author_time = int(time.time())
175
+ commit.commit_timezone = commit.author_timezone = 0
176
+ commit.message = b"First commit"
177
+
178
+ commit_id = self._repo.object_store.add_object(commit)
179
+ # commit_id may be None per dulwich typing; only set if present
180
+ if commit_id is not None:
181
+ self._repo.refs[b"refs/heads/main"] = commit_id
182
+ commit_success = True
183
+ except Exception:
184
+ pass
185
+
186
+ if commit_success:
187
+ # After commit, remove the master branch reference if it was created
188
+ try:
189
+ if b"refs/heads/master" in self._repo.refs:
190
+ del self._repo.refs[b"refs/heads/master"]
191
+ except Exception:
192
+ pass
193
+
194
+ return True
195
+ except Exception:
196
+ return False
197
+
198
+ def get_current_branch(self) -> Optional[str]:
199
+ """Get the current branch name"""
200
+ if not self._repo:
201
+ return None
202
+
203
+ try:
204
+ # Get HEAD reference from dulwich refs (more reliable than file system)
205
+ head_ref = self._repo.refs[b"HEAD"] # type: ignore
206
+ if head_ref.startswith(b"ref: "):
207
+ ref_path = head_ref[5:].decode("utf-8")
208
+ if ref_path.startswith("refs/heads/"):
209
+ return ref_path[11:] # Remove "refs/heads/"
210
+
211
+ # For repositories without commits, fallback to reading HEAD file directly
212
+ try:
213
+ head_file = os.path.join(self._repo.path, ".git", "HEAD")
214
+ with open(head_file, "r", encoding="utf-8") as f:
215
+ head_content = f.read().strip()
216
+ if head_content.startswith("ref: refs/heads/"):
217
+ return head_content[16:] # Remove "ref: refs/heads/"
218
+ except Exception:
219
+ pass
220
+
221
+ return None
222
+ except Exception:
223
+ return None
224
+
225
+ def get_all_branches(self) -> List[str]:
226
+ """Get all local branches"""
227
+ if not self._repo:
228
+ return []
229
+
230
+ if not self._repo:
231
+ return []
232
+ try:
233
+ branches = []
234
+ for ref_name, _ in self._repo.refs.as_dict().items(): # type: ignore
235
+ if ref_name.startswith(b"refs/heads/"):
236
+ branch_name = ref_name[11:].decode("utf-8") # Remove "refs/heads/"
237
+ branches.append(branch_name)
238
+ return branches
239
+ except Exception:
240
+ return []
241
+
242
+ def get_last_commit(self) -> Optional[GitCommit]:
243
+ """Get information about the last commit"""
244
+ commits = self.get_commit_history(limit=1)
245
+ return commits[0] if commits else None
246
+
247
+ def get_commit_history(
248
+ self, limit: int = 10, offset: int = 0, branch: Optional[str] = None
249
+ ) -> List[GitCommit]:
250
+ """Get commit history with pagination"""
251
+ if not self._repo:
252
+ return []
253
+
254
+ try:
255
+ commits = []
256
+
257
+ if branch:
258
+ # Get commits from specific branch only
259
+ try:
260
+ branch_ref = f"refs/heads/{branch}".encode()
261
+ if branch_ref in self._repo.refs:
262
+ ref_sha = self._repo.refs[branch_ref]
263
+ all_refs = [ref_sha]
264
+ else:
265
+ # Try remote branch
266
+ remote_ref = f"refs/remotes/origin/{branch}".encode()
267
+ if remote_ref in self._repo.refs:
268
+ ref_sha = self._repo.refs[remote_ref]
269
+ all_refs = [ref_sha]
270
+ else:
271
+ return []
272
+ except Exception:
273
+ return []
274
+ else:
275
+ # Get all branch refs to include commits from all branches
276
+ all_refs = []
277
+ for ref_name, ref_sha in self._repo.refs.as_dict().items():
278
+ if ref_name.startswith(b"refs/heads/") or ref_name.startswith(
279
+ b"refs/remotes/"
280
+ ):
281
+ all_refs.append(ref_sha)
282
+
283
+ # If no refs found, fallback to HEAD
284
+ if not all_refs:
285
+ all_refs = [self._repo.head()]
286
+
287
+ walker = self._repo.get_walker(include=all_refs, max_entries=limit + offset) # type: ignore
288
+
289
+ # Skip offset entries
290
+ for i, entry in enumerate(walker):
291
+ if i < offset:
292
+ continue
293
+ if len(commits) >= limit:
294
+ break
295
+
296
+ commit = entry.commit
297
+ # Convert timestamp to date string to match native format
298
+ date_obj = datetime.datetime.fromtimestamp(commit.commit_time)
299
+ # Use ISO format with time, matching native implementation
300
+ date_str = date_obj.strftime("%Y-%m-%d %H:%M:%S")
301
+
302
+ commits.append(
303
+ GitCommit(
304
+ hash=commit.id.decode("ascii"),
305
+ message=commit.message.decode("utf-8", errors="ignore").strip(),
306
+ author=commit.author.decode("utf-8", errors="ignore")
307
+ .split("<")[0]
308
+ .strip(),
309
+ date=date_str,
310
+ )
311
+ )
312
+
313
+ return commits
314
+ except Exception:
315
+ return []
316
+
317
+ def get_changed_files(self) -> List[str]:
318
+ """Get list of changed files"""
319
+ if not self._repo:
320
+ return []
321
+
322
+ try:
323
+ # Get status using dulwich - porcelain.status expects repo path
324
+ status = porcelain.status(str(self.working_directory)) # type: ignore
325
+
326
+ changed_files = []
327
+
328
+ # Add staged files
329
+ for file_path in status.staged["add"]: # type: ignore
330
+ if isinstance(file_path, bytes):
331
+ changed_files.append(file_path.decode("utf-8"))
332
+ else:
333
+ changed_files.append(file_path)
334
+ for file_path in status.staged["delete"]: # type: ignore
335
+ if isinstance(file_path, bytes):
336
+ changed_files.append(file_path.decode("utf-8"))
337
+ else:
338
+ changed_files.append(file_path)
339
+ for file_path in status.staged["modify"]: # type: ignore
340
+ if isinstance(file_path, bytes):
341
+ changed_files.append(file_path.decode("utf-8"))
342
+ else:
343
+ changed_files.append(file_path)
344
+
345
+ # Add unstaged files
346
+ for file_path in status.unstaged: # type: ignore
347
+ if isinstance(file_path, bytes):
348
+ changed_files.append(file_path.decode("utf-8"))
349
+ else:
350
+ changed_files.append(file_path)
351
+
352
+ # Add untracked files
353
+ for file_path in status.untracked: # type: ignore
354
+ if isinstance(file_path, bytes):
355
+ changed_files.append(file_path.decode("utf-8"))
356
+ else:
357
+ changed_files.append(file_path)
358
+
359
+ return sorted(list(set(changed_files)))
360
+ except Exception:
361
+ return []
362
+
363
+ def get_changed_files_with_status(self) -> List[GitFileChange]:
364
+ """Get list of changed files with their git status"""
365
+ if not self._repo:
366
+ return []
367
+
368
+ try:
369
+ # Get status using dulwich - porcelain.status expects repo path
370
+ status = porcelain.status(str(self.working_directory)) # type: ignore
371
+
372
+ files = []
373
+
374
+ # Add staged files
375
+ for file_path in status.staged["add"]: # type: ignore
376
+ filename = (
377
+ file_path.decode("utf-8")
378
+ if isinstance(file_path, bytes)
379
+ else str(file_path)
380
+ )
381
+ files.append(
382
+ GitFileChange(path=filename, status="added", status_code="A ")
383
+ )
384
+
385
+ for file_path in status.staged["delete"]: # type: ignore
386
+ filename = (
387
+ file_path.decode("utf-8")
388
+ if isinstance(file_path, bytes)
389
+ else str(file_path)
390
+ )
391
+ files.append(
392
+ GitFileChange(path=filename, status="deleted", status_code="D ")
393
+ )
394
+
395
+ for file_path in status.staged["modify"]: # type: ignore
396
+ filename = (
397
+ file_path.decode("utf-8")
398
+ if isinstance(file_path, bytes)
399
+ else str(file_path)
400
+ )
401
+ files.append(
402
+ GitFileChange(path=filename, status="modified", status_code="M ")
403
+ )
404
+
405
+ # Add unstaged files
406
+ for file_path in status.unstaged: # type: ignore
407
+ filename = (
408
+ file_path.decode("utf-8")
409
+ if isinstance(file_path, bytes)
410
+ else str(file_path)
411
+ )
412
+ files.append(
413
+ GitFileChange(path=filename, status="modified", status_code=" M")
414
+ )
415
+
416
+ # Add untracked files
417
+ for file_path in status.untracked: # type: ignore
418
+ filename = (
419
+ file_path.decode("utf-8")
420
+ if isinstance(file_path, bytes)
421
+ else str(file_path)
422
+ )
423
+ files.append(
424
+ GitFileChange(path=filename, status="untracked", status_code="??")
425
+ )
426
+
427
+ return files
428
+ except Exception:
429
+ return []
430
+
431
+ def has_uncommitted_changes(self) -> bool:
432
+ """Check if there are uncommitted changes"""
433
+ return len(self.get_changed_files()) > 0
434
+
435
+ def get_repository_status(self) -> GitStatus:
436
+ """Get comprehensive repository status"""
437
+ if not self.is_git_available():
438
+ return GitStatus(available=False)
439
+
440
+ git_root = self.find_git_root()
441
+ if (
442
+ git_root is not None
443
+ and git_root.resolve() != self.working_directory.resolve() # type: ignore
444
+ ):
445
+ return GitStatus(available=False)
446
+
447
+ original_working_dir = None
448
+ if self.is_git_repository():
449
+ git_root = self.working_directory
450
+ elif self.find_git_root():
451
+ git_root = self.find_git_root()
452
+ original_working_dir = self.working_directory
453
+ self.working_directory = git_root
454
+ self._try_load_repo()
455
+ elif self.init_repository():
456
+ git_root = self.working_directory
457
+ else:
458
+ return GitStatus(available=False)
459
+
460
+ try:
461
+ branch = self.get_current_branch()
462
+ if not branch:
463
+ # Check if we're in detached HEAD state or new repo
464
+ try:
465
+ head_ref = self._repo.refs[b"HEAD"] # type: ignore
466
+ if isinstance(head_ref, bytes) and len(head_ref) == 40: # SHA hash
467
+ branch = f"detached-{head_ref[:8].decode('ascii')}"
468
+ else:
469
+ # For new repositories without commits, default to "main"
470
+ branch = "main"
471
+ except Exception:
472
+ # For new repositories without commits, default to "main"
473
+ branch = "main"
474
+
475
+ branches = self.get_all_branches()
476
+ last_commit = self.get_last_commit()
477
+ changed_files = self.get_changed_files()
478
+ changed_files_with_status = self.get_changed_files_with_status()
479
+ has_changes = len(changed_files) > 0
480
+
481
+ return GitStatus(
482
+ available=True,
483
+ branch=branch,
484
+ branches=branches,
485
+ last_commit=last_commit,
486
+ has_changes=has_changes,
487
+ changed_files=changed_files,
488
+ changed_files_with_status=changed_files_with_status,
489
+ )
490
+ finally:
491
+ if original_working_dir is not None:
492
+ self.working_directory = original_working_dir
493
+
494
+ def checkout_branch(self, branch_name: str) -> bool:
495
+ """Switch to a different branch"""
496
+ if not self._repo:
497
+ return False
498
+
499
+ try:
500
+ # First try to checkout existing local branch
501
+ local_branch_ref = f"refs/heads/{branch_name}".encode()
502
+ if local_branch_ref in self._repo.refs:
503
+ porcelain.checkout_branch(
504
+ str(self.working_directory), branch_name.encode("utf-8")
505
+ ) # type: ignore
506
+ return True
507
+
508
+ # If local branch doesn't exist, try to create from remote
509
+ remote_branch_ref = f"refs/remotes/origin/{branch_name}".encode()
510
+ if remote_branch_ref in self._repo.refs:
511
+ # Create local branch tracking remote
512
+ remote_commit = self._repo.refs[remote_branch_ref]
513
+ self._repo.refs[local_branch_ref] = remote_commit
514
+ porcelain.checkout_branch(
515
+ str(self.working_directory), branch_name.encode("utf-8")
516
+ ) # type: ignore
517
+ return True
518
+
519
+ return False
520
+ except Exception:
521
+ return False
522
+
523
+ def checkout_commit(self, commit_hash: str) -> bool:
524
+ """Switch to a specific commit (detached HEAD state)"""
525
+ if not self._repo:
526
+ return False
527
+
528
+ try:
529
+ # Set HEAD to point directly to the commit (detached HEAD)
530
+ self._repo.refs[b"HEAD"] = commit_hash.encode("utf-8") # type: ignore
531
+ # Reset working directory to match the commit using dulwich's reset_hard equivalent
532
+ porcelain.reset(
533
+ str(self.working_directory), "hard", commit_hash.encode("utf-8")
534
+ ) # type: ignore
535
+ return True
536
+ except Exception:
537
+ return False
538
+
539
+ def pull_changes(
540
+ self,
541
+ strategy: str = "merge",
542
+ allow_unrelated: bool = True,
543
+ conflict_resolution: Optional[str] = "theirs",
544
+ ) -> bool:
545
+ """Pull changes from abstra remote repository"""
546
+ if not self._repo:
547
+ return False
548
+
549
+ if not self.has_remote(REMOTE_NAME):
550
+ return False
551
+
552
+ try:
553
+ try:
554
+ if not self._needs_auth():
555
+ porcelain.pull(
556
+ self._repo,
557
+ REMOTE_NAME,
558
+ # Add strategy flags similar to native
559
+ )
560
+ return True
561
+ except Exception:
562
+ pass # Fall through to custom implementation
563
+
564
+ # Custom fetch with auth
565
+ remote_url = self._get_remote_url(REMOTE_NAME)
566
+ if not remote_url:
567
+ return False
568
+
569
+ client = self._create_authenticated_client(remote_url)
570
+ if not client:
571
+ return False
572
+
573
+ try:
574
+ # Perform fetch
575
+ parsed_url = urlparse(remote_url)
576
+ repo_path = parsed_url.path.lstrip("/") if parsed_url.path else ""
577
+
578
+ result = client.fetch(
579
+ repo_path,
580
+ self._repo,
581
+ determine_wants=lambda refs, depth=None: list(refs.values()),
582
+ )
583
+
584
+ # Update remote tracking branches properly
585
+ self._update_remote_tracking_refs(result, REMOTE_NAME)
586
+
587
+ # Get the remote branch we want to merge
588
+ remote_branch_ref = f"refs/remotes/{REMOTE_NAME}/main".encode()
589
+ if remote_branch_ref not in self._repo.refs:
590
+ AbstraLogger.warning("No remote main branch found after fetch")
591
+ return False
592
+
593
+ remote_commit_sha = self._repo.refs[remote_branch_ref]
594
+ current_head = self._repo.head()
595
+
596
+ # Check if we're already up to date
597
+ if current_head == remote_commit_sha:
598
+ AbstraLogger.debug("Already up to date")
599
+ return True
600
+
601
+ # Perform the merge/pull based on conflict resolution
602
+ if conflict_resolution == "theirs":
603
+ # Simply move HEAD to remote commit (equivalent to git reset --hard origin/main)
604
+ self._repo.refs[b"HEAD"] = remote_commit_sha
605
+ self._reset_working_tree_to_head()
606
+
607
+ # Also update the local main branch to track remote
608
+ self._repo.refs[b"refs/heads/main"] = remote_commit_sha
609
+
610
+ AbstraLogger.info(
611
+ f"Successfully pulled changes from {REMOTE_NAME} (using theirs)"
612
+ )
613
+ return True
614
+
615
+ elif conflict_resolution == "ours":
616
+ # Keep local changes, just update remote tracking
617
+ AbstraLogger.info("Kept local changes (using ours)")
618
+ return True
619
+
620
+ else:
621
+ # Default behavior - try fast-forward, otherwise use "theirs"
622
+ if self._is_ancestor(current_head, remote_commit_sha):
623
+ # Fast-forward merge
624
+ self._repo.refs[b"HEAD"] = remote_commit_sha
625
+ self._repo.refs[b"refs/heads/main"] = remote_commit_sha
626
+ self._reset_working_tree_to_head()
627
+ AbstraLogger.info(
628
+ f"Successfully pulled changes from {REMOTE_NAME} (fast-forward)"
629
+ )
630
+ return True
631
+ else:
632
+ # Non-fast-forward, default to "theirs" strategy
633
+ self._repo.refs[b"HEAD"] = remote_commit_sha
634
+ self._repo.refs[b"refs/heads/main"] = remote_commit_sha
635
+ self._reset_working_tree_to_head()
636
+ AbstraLogger.info(
637
+ f"Successfully pulled changes from {REMOTE_NAME}"
638
+ )
639
+ return True
640
+
641
+ except Exception as e:
642
+ AbstraLogger.error(f"Error during fetch/merge: {e}")
643
+ return False
644
+
645
+ except Exception as e:
646
+ AbstraLogger.error(f"Error during git pull: {e}")
647
+ return False
648
+
649
+ def _needs_auth(self) -> bool:
650
+ """Check if authentication is needed"""
651
+ try:
652
+ remote_url = self._get_remote_url(REMOTE_NAME)
653
+ if not remote_url:
654
+ return False
655
+ config_key = f"http.{remote_url}.extraHeader"
656
+ auth_header = self.get_git_config(config_key)
657
+ return bool(auth_header)
658
+ except Exception:
659
+ return False
660
+
661
+ def _get_remote_url(self, remote_name: str) -> Optional[str]:
662
+ """Get remote URL"""
663
+ try:
664
+ if not self._repo or not hasattr(self._repo, "get_config"):
665
+ return None
666
+ config = self._repo.get_config()
667
+ remote_url = config.get((b"remote", remote_name.encode("utf-8")), b"url")
668
+ return remote_url.decode("utf-8") if remote_url else None
669
+ except Exception:
670
+ return None
671
+
672
+ def _create_authenticated_client(self, remote_url: str):
673
+ """Create HTTP client with authentication"""
674
+ try:
675
+ auth_header = None
676
+ config_key = f"http.{remote_url}.extraHeader"
677
+ auth_header = self.get_git_config(config_key)
678
+
679
+ if not auth_header:
680
+ return None
681
+
682
+ parsed_url = urlparse(remote_url)
683
+ base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
684
+
685
+ # Parse auth header
686
+ if ":" not in auth_header:
687
+ return None
688
+
689
+ header_name, header_value = auth_header.split(":", 1)
690
+ custom_headers = {
691
+ header_name.strip(): header_value.strip(),
692
+ "User-Agent": "dulwich/0.24.1",
693
+ }
694
+
695
+ # Create client with custom headers
696
+ pool_manager = urllib3.PoolManager(headers=custom_headers)
697
+ client = HttpGitClient(base_url)
698
+ client.pool_manager = pool_manager
699
+
700
+ return client
701
+ except Exception:
702
+ return None
703
+
704
+ def _update_remote_tracking_refs(self, fetch_result, remote_name: str):
705
+ """Update remote tracking references after fetch"""
706
+ try:
707
+ if not self._repo or not hasattr(self._repo, "refs"):
708
+ return
709
+ for ref_name, ref_sha in fetch_result.refs.items():
710
+ if ref_name.startswith(b"refs/heads/"):
711
+ branch_name = ref_name[11:]
712
+ remote_ref = f"refs/remotes/{remote_name}/".encode() + branch_name
713
+ self._repo.refs[remote_ref] = ref_sha
714
+
715
+ for symref_name, symref_target in fetch_result.symrefs.items():
716
+ if symref_name == b"HEAD" and symref_target.startswith(b"refs/heads/"):
717
+ branch_name = symref_target[11:]
718
+ remote_head_ref = f"refs/remotes/{remote_name}/HEAD".encode()
719
+ remote_branch_ref = (
720
+ f"refs/remotes/{remote_name}/".encode() + branch_name
721
+ )
722
+ if remote_branch_ref in self._repo.refs:
723
+ self._repo.refs.set_symbolic_ref(
724
+ remote_head_ref, remote_branch_ref
725
+ )
726
+ except Exception as e:
727
+ AbstraLogger.debug(f"Error updating remote tracking refs: {e}")
728
+
729
+ # Remove duplicate _reset_working_tree_to_head method
730
+
731
+ def _is_ancestor(self, ancestor_sha: bytes, descendant_sha: bytes) -> bool:
732
+ """Check if ancestor_sha is an ancestor of descendant_sha"""
733
+ if not self._repo:
734
+ return False
735
+
736
+ try:
737
+ # Simple check: if we can walk from descendant back to ancestor
738
+ walker = self._repo.get_walker(include=[descendant_sha])
739
+ for entry in walker:
740
+ if entry.commit.id == ancestor_sha:
741
+ return True
742
+ return False
743
+ except Exception:
744
+ return False
745
+
746
+ def _reset_working_tree_to_head(self) -> bool:
747
+ """Reset working tree to match HEAD"""
748
+ if not self._repo:
749
+ return False
750
+ try:
751
+ # Use dulwich's porcelain reset function for a proper hard reset
752
+ reset(str(self.working_directory), "hard", "HEAD")
753
+ AbstraLogger.debug("Successfully reset working tree to HEAD")
754
+ return True
755
+ except Exception as e:
756
+ AbstraLogger.debug(f"Failed to reset working tree: {e}")
757
+ # Try alternative approach
758
+ try:
759
+ # Get current HEAD commit
760
+ head_sha = self._repo.head() if self._repo else None
761
+ if not head_sha:
762
+ return False
763
+ commit_obj = self._repo[head_sha] if self._repo else None
764
+ if not commit_obj or not isinstance(commit_obj, Commit):
765
+ AbstraLogger.debug("HEAD does not point to a valid commit")
766
+ return False
767
+ # Get the tree object
768
+ tree = (
769
+ self._repo[commit_obj.tree]
770
+ if self._repo and hasattr(commit_obj, "tree")
771
+ else None
772
+ )
773
+ if not tree:
774
+ return False
775
+ # Update index to match tree
776
+ index_path = (
777
+ os.path.join(self._repo.path, "index") if self._repo else "index"
778
+ )
779
+ if (
780
+ self._repo
781
+ and hasattr(self._repo, "object_store")
782
+ and self._repo.object_store is not None
783
+ ):
784
+ build_index_from_tree(
785
+ self._repo.path, index_path, self._repo.object_store, tree.id
786
+ )
787
+ else:
788
+ AbstraLogger.debug(
789
+ "object_store is None, cannot build index from tree"
790
+ )
791
+ return False
792
+ AbstraLogger.debug("Successfully updated index")
793
+ return True
794
+ except Exception as e2:
795
+ AbstraLogger.debug(f"Alternative reset also failed: {e2}")
796
+ return False
797
+
798
+ def commit_changes(self, message: str, add_all: bool = True) -> bool:
799
+ """Commit changes with a message"""
800
+ if not self._repo:
801
+ return False
802
+
803
+ try:
804
+ if add_all:
805
+ # Add all files - porcelain.add expects repo path, not Repo object
806
+ porcelain.add(
807
+ str(self.working_directory), [str(self.working_directory)]
808
+ ) # type: ignore
809
+
810
+ # Commit changes - porcelain.commit expects repo path, not Repo object
811
+ porcelain.commit(str(self.working_directory), message.encode("utf-8")) # type: ignore
812
+ return True
813
+ except Exception:
814
+ return False
815
+
816
+ def stash_changes(self, message: str = "WIP") -> bool:
817
+ """Stash uncommitted changes using Dulwich Stash"""
818
+ if not self._repo:
819
+ return False
820
+
821
+ try:
822
+ # Use Dulwich Stash class
823
+ stash = Stash.from_repo(self._repo) # type: ignore
824
+ stash.push(message=message.encode("utf-8")) # type: ignore
825
+ return True
826
+ except Exception:
827
+ return False
828
+
829
+ def get_remotes(self) -> List[str]:
830
+ """Get list of remote names"""
831
+ if not self._repo:
832
+ return []
833
+
834
+ if not self._repo:
835
+ return []
836
+ try:
837
+ config = self._repo.get_config() # type: ignore
838
+ remotes = []
839
+ for section_name in config.sections():
840
+ if len(section_name) >= 2 and section_name[0] == b"remote":
841
+ remote_name = section_name[1].decode("utf-8")
842
+ remotes.append(remote_name)
843
+ return remotes
844
+ except Exception as e:
845
+ AbstraLogger.error(f"Error getting remotes: {e}")
846
+ return []
847
+
848
+ def has_remote(self, remote_name: str) -> bool:
849
+ """Check if a remote exists"""
850
+ remotes = self.get_remotes()
851
+ if not remotes:
852
+ return False
853
+ return remote_name in remotes
854
+
855
+ def add_remote(self, remote_name: str, remote_url: str) -> bool:
856
+ """Add a remote to the repository"""
857
+ if not self._repo:
858
+ return False
859
+ try:
860
+ config = self._repo.get_config() # type: ignore
861
+ config.set(
862
+ (b"remote", remote_name.encode("utf-8")),
863
+ b"url",
864
+ remote_url.encode("utf-8"),
865
+ )
866
+ config.write_to_path()
867
+ return True
868
+ except Exception as e:
869
+ AbstraLogger.error(f"Error adding remote {remote_name}: {e}")
870
+ return False
871
+
872
+ def set_remote_url(self, remote_name: str, remote_url: str) -> bool:
873
+ """Set/update the URL for a remote"""
874
+ if not self._repo:
875
+ return False
876
+
877
+ try:
878
+ config = self._repo.get_config() # type: ignore
879
+ config.set(
880
+ (b"remote", remote_name.encode("utf-8")),
881
+ b"url",
882
+ remote_url.encode("utf-8"),
883
+ )
884
+ config.write_to_path()
885
+ return True
886
+ except Exception as e:
887
+ AbstraLogger.error(f"Error setting remote URL for {remote_name}: {e}")
888
+ return False
889
+
890
+ def set_git_config(self, key: str, value: str, local: bool = True) -> bool:
891
+ """Set a git configuration value"""
892
+ if not self._repo:
893
+ return False
894
+
895
+ try:
896
+ config = self._repo.get_config() # type: ignore
897
+
898
+ # Handle special case for http.<url>.* configs
899
+ if key.startswith("http.") and key.count(".") >= 2:
900
+ # For keys like "http.https://example.com/repo.git.extraHeader"
901
+ # We need to find the last dot that separates the option from the URL
902
+ # Split from the right to separate option from URL
903
+ http_prefix = "http."
904
+ remaining = key[len(http_prefix) :] # Remove "http." prefix
905
+
906
+ # Find the last dot to separate option from URL
907
+ last_dot_index = remaining.rfind(".")
908
+ if last_dot_index > 0:
909
+ url_part = remaining[:last_dot_index]
910
+ option_part = remaining[last_dot_index + 1 :]
911
+
912
+ config.set(
913
+ (b"http", url_part.encode("utf-8")),
914
+ option_part.encode("utf-8"),
915
+ value.encode("utf-8"),
916
+ )
917
+ config.write_to_path()
918
+ return True
919
+
920
+ # Handle normal config keys like "user.name", "core.editor"
921
+ parts = key.split(".", 1)
922
+ if len(parts) == 2:
923
+ section, option = parts
924
+ config.set(
925
+ (section.encode("utf-8"),),
926
+ option.encode("utf-8"),
927
+ value.encode("utf-8"),
928
+ )
929
+ config.write_to_path()
930
+ return True
931
+ return False
932
+ except Exception as e:
933
+ AbstraLogger.error(f"Error setting git config {key}: {e}")
934
+ return False
935
+
936
+ def get_git_config(self, key: str, local: bool = True) -> Optional[str]:
937
+ """Get a git configuration value"""
938
+ if not self._repo:
939
+ return None
940
+
941
+ try:
942
+ config = self._repo.get_config() # type: ignore
943
+
944
+ # Handle special case for http.<url>.* configs
945
+ if key.startswith("http.") and key.count(".") >= 2:
946
+ # For keys like "http.https://example.com/repo.git.extraHeader"
947
+ # We need to find the last dot that separates the option from the URL
948
+ # Split from the right to separate option from URL
949
+ http_prefix = "http."
950
+ remaining = key[len(http_prefix) :] # Remove "http." prefix
951
+
952
+ # Find the last dot to separate option from URL
953
+ last_dot_index = remaining.rfind(".")
954
+ if last_dot_index > 0:
955
+ url_part = remaining[:last_dot_index]
956
+ option_part = remaining[last_dot_index + 1 :]
957
+
958
+ try:
959
+ value = config.get(
960
+ (b"http", url_part.encode("utf-8")),
961
+ option_part.encode("utf-8"),
962
+ )
963
+ return value.decode("utf-8") if value else None
964
+ except KeyError:
965
+ return None
966
+
967
+ # Handle normal config keys like "user.name", "core.editor"
968
+ parts = key.split(".", 1)
969
+ if len(parts) == 2:
970
+ section, option = parts
971
+ try:
972
+ value = config.get(
973
+ (section.encode("utf-8"),), option.encode("utf-8")
974
+ )
975
+ return value.decode("utf-8") if value else None
976
+ except KeyError:
977
+ return None
978
+ return None
979
+ except Exception:
980
+ return None
981
+
982
+ def push_and_deploy(self, branch: str = "main") -> bool:
983
+ """Deploy to Abstra remote (push to abstra remote)"""
984
+ if not self._repo:
985
+ return False
986
+ if not self.has_remote(REMOTE_NAME):
987
+ return False
988
+ try:
989
+ branch_name = branch if branch else "main"
990
+ branch_ref = f"refs/heads/{branch_name}".encode()
991
+ if not hasattr(self._repo, "refs") or branch_ref not in self._repo.refs:
992
+ return False
993
+ porcelain.push(
994
+ str(self.working_directory),
995
+ REMOTE_NAME,
996
+ refspecs=[f"{branch_name}:{branch_name}"],
997
+ )
998
+ return True
999
+ except Exception:
1000
+ return False
1001
+
1002
+ def revert_commit(self, commit_hash: str) -> bool:
1003
+ """Reset working directory to match a previous commit and create a new commit with that content"""
1004
+ if not self._repo:
1005
+ return False
1006
+ if not commit_hash or not commit_hash.strip():
1007
+ return False
1008
+ if self.has_uncommitted_changes():
1009
+ return False
1010
+ try:
1011
+ # Verify the commit exists
1012
+ try:
1013
+ if not hasattr(self._repo, "__getitem__"):
1014
+ return False
1015
+ self._repo[commit_hash.encode()]
1016
+ except KeyError:
1017
+ return False
1018
+ # Get current HEAD
1019
+ try:
1020
+ current_head = (
1021
+ self._repo.head() if hasattr(self._repo, "head") else None
1022
+ )
1023
+ if not current_head:
1024
+ return False
1025
+ self._repo[current_head]
1026
+ except Exception:
1027
+ return False
1028
+ # Step 1: Reset working directory to target commit state
1029
+ porcelain.reset(str(self.working_directory), "hard", commit_hash.encode())
1030
+ # Step 2: Reset HEAD back to original position (but keep working directory)
1031
+ if hasattr(self._repo, "refs"):
1032
+ self._repo.refs[b"HEAD"] = current_head
1033
+ # Step 3: Add all changes and create new commit
1034
+ porcelain.add(str(self.working_directory), [str(self.working_directory)])
1035
+ commit_message = f"Restore content from commit {commit_hash[:8]}"
1036
+ porcelain.commit(
1037
+ str(self.working_directory), commit_message.encode("utf-8")
1038
+ )
1039
+ return True
1040
+ except Exception:
1041
+ try:
1042
+ porcelain.reset(str(self.working_directory), "hard")
1043
+ except Exception:
1044
+ pass
1045
+ return False
1046
+
1047
+ def check_merge_conflicts(self, remote_commit: str) -> bool:
1048
+ """Check if merging with remote commit would cause conflicts"""
1049
+ if not self._repo:
1050
+ return False
1051
+
1052
+ if not remote_commit:
1053
+ return False
1054
+
1055
+ try:
1056
+ try:
1057
+ current_commit_id = self._repo.head()
1058
+ except Exception:
1059
+ return False
1060
+
1061
+ try:
1062
+ if isinstance(remote_commit, str):
1063
+ remote_commit_bytes = remote_commit.encode()
1064
+ else:
1065
+ remote_commit_bytes = remote_commit
1066
+ except KeyError:
1067
+ # If remote commit doesn't exist locally, this is equivalent to merge-base failing
1068
+ # Native git returns True (conflicts) in this case
1069
+ return True
1070
+
1071
+ # Try to find merge base (equivalent to git merge-base)
1072
+ try:
1073
+ merge_base_id = self._find_merge_base_dulwich(
1074
+ current_commit_id, remote_commit_bytes
1075
+ )
1076
+ if merge_base_id is None:
1077
+ return True
1078
+ except Exception:
1079
+ return True
1080
+
1081
+ # Now run merge-tree equivalent (equivalent to git merge-tree)
1082
+ try:
1083
+ has_conflicts = self._simulate_merge_tree_dulwich(
1084
+ merge_base_id, current_commit_id, remote_commit_bytes
1085
+ )
1086
+ return has_conflicts
1087
+ except Exception:
1088
+ return False
1089
+
1090
+ except Exception:
1091
+ return False
1092
+
1093
+ def _find_merge_base_dulwich(self, commit1_id, commit2_id):
1094
+ """Find merge base between two commits - equivalent to git merge-base"""
1095
+ try:
1096
+ if not self._repo:
1097
+ return None
1098
+ if isinstance(commit2_id, str):
1099
+ commit2_id = commit2_id.encode()
1100
+ from collections import deque
1101
+
1102
+ visited1 = set()
1103
+ queue1 = deque([commit1_id])
1104
+ while queue1:
1105
+ commit_id = queue1.popleft()
1106
+ if commit_id in visited1:
1107
+ continue
1108
+ visited1.add(commit_id)
1109
+ try:
1110
+ commit_obj = (
1111
+ self._repo[commit_id]
1112
+ if hasattr(self._repo, "__getitem__")
1113
+ else None
1114
+ )
1115
+ if (
1116
+ commit_obj
1117
+ and isinstance(commit_obj, Commit)
1118
+ and hasattr(commit_obj, "parents")
1119
+ ):
1120
+ queue1.extend(commit_obj.parents)
1121
+ except Exception as e:
1122
+ print(f"Error accessing commit {commit_id}: {e}")
1123
+ continue
1124
+ visited2 = set()
1125
+ queue2 = deque([commit2_id])
1126
+ while queue2:
1127
+ commit_id = queue2.popleft()
1128
+ if commit_id in visited2:
1129
+ continue
1130
+ visited2.add(commit_id)
1131
+ if commit_id in visited1:
1132
+ return commit_id
1133
+ try:
1134
+ commit_obj = (
1135
+ self._repo[commit_id]
1136
+ if hasattr(self._repo, "__getitem__")
1137
+ else None
1138
+ )
1139
+ if (
1140
+ commit_obj
1141
+ and isinstance(commit_obj, Commit)
1142
+ and hasattr(commit_obj, "parents")
1143
+ ):
1144
+ queue2.extend(commit_obj.parents)
1145
+ except Exception as e:
1146
+ print(f"Error accessing commit {commit_id}: {e}")
1147
+ continue
1148
+ return None
1149
+ except Exception as e:
1150
+ print(f"Error in _find_merge_base_dulwich: {e}")
1151
+ return None
1152
+
1153
+ def _simulate_merge_tree_dulwich(self, merge_base_id, current_id, remote_id):
1154
+ """Simulate git merge-tree to detect conflicts"""
1155
+ try:
1156
+ if not self._repo:
1157
+ return False
1158
+ if isinstance(remote_id, str):
1159
+ remote_id = remote_id.encode()
1160
+ base_commit = (
1161
+ self._repo[merge_base_id]
1162
+ if hasattr(self._repo, "__getitem__")
1163
+ else None
1164
+ )
1165
+ current_commit = (
1166
+ self._repo[current_id] if hasattr(self._repo, "__getitem__") else None
1167
+ )
1168
+ remote_commit = (
1169
+ self._repo[remote_id] if hasattr(self._repo, "__getitem__") else None
1170
+ )
1171
+ if not base_commit or not current_commit or not remote_commit:
1172
+ return False
1173
+ base_tree = (
1174
+ self._repo[base_commit.tree]
1175
+ if base_commit
1176
+ and isinstance(base_commit, Commit)
1177
+ and hasattr(base_commit, "tree")
1178
+ and hasattr(self._repo, "__getitem__")
1179
+ else None
1180
+ )
1181
+ current_tree = (
1182
+ self._repo[current_commit.tree]
1183
+ if current_commit
1184
+ and isinstance(current_commit, Commit)
1185
+ and hasattr(current_commit, "tree")
1186
+ and hasattr(self._repo, "__getitem__")
1187
+ else None
1188
+ )
1189
+ remote_tree = (
1190
+ self._repo[remote_commit.tree]
1191
+ if remote_commit
1192
+ and isinstance(remote_commit, Commit)
1193
+ and hasattr(remote_commit, "tree")
1194
+ and hasattr(self._repo, "__getitem__")
1195
+ else None
1196
+ )
1197
+ if not base_tree or not current_tree or not remote_tree:
1198
+ return False
1199
+ if base_tree.id == current_tree.id == remote_tree.id:
1200
+ return False
1201
+ if current_tree.id == base_tree.id:
1202
+ return False
1203
+ if remote_tree.id == base_tree.id:
1204
+ return False
1205
+ if current_tree.id == remote_tree.id:
1206
+ return False
1207
+ return self._analyze_tree_conflicts(base_tree, current_tree, remote_tree)
1208
+ except Exception as e:
1209
+ print(f"Error in _simulate_merge_tree_dulwich: {e}")
1210
+ return False
1211
+
1212
+ def _analyze_tree_conflicts(self, base_tree, current_tree, remote_tree):
1213
+ """Analyze trees for conflicts like git merge-tree does"""
1214
+ try:
1215
+ # Get all file entries from all three trees
1216
+ base_entries = self._get_tree_entries(base_tree)
1217
+ current_entries = self._get_tree_entries(current_tree)
1218
+ remote_entries = self._get_tree_entries(remote_tree)
1219
+
1220
+ # Get all unique file paths
1221
+ all_paths = (
1222
+ set(base_entries.keys())
1223
+ | set(current_entries.keys())
1224
+ | set(remote_entries.keys())
1225
+ )
1226
+
1227
+ for path in all_paths:
1228
+ base_entry = base_entries.get(path)
1229
+ current_entry = current_entries.get(path)
1230
+ remote_entry = remote_entries.get(path)
1231
+ # Only check for conflicts if all entries are not None and are tuples
1232
+ if (
1233
+ (base_entry is not None and isinstance(base_entry, tuple))
1234
+ or (current_entry is not None and isinstance(current_entry, tuple))
1235
+ or (remote_entry is not None and isinstance(remote_entry, tuple))
1236
+ ):
1237
+ try:
1238
+ if self._has_file_conflict(
1239
+ base_entry, current_entry, remote_entry
1240
+ ):
1241
+ return True
1242
+ except TypeError:
1243
+ continue
1244
+
1245
+ return False
1246
+
1247
+ except Exception as e:
1248
+ print(f"Error in _analyze_tree_conflicts: {e}")
1249
+ return True # Conservative: assume conflicts on error
1250
+
1251
+ def _get_tree_entries(self, tree):
1252
+ """Get all file entries from a tree recursively"""
1253
+ entries = {}
1254
+
1255
+ def walk_tree(tree_obj, prefix=""):
1256
+ try:
1257
+ if not tree_obj or not hasattr(tree_obj, "items"):
1258
+ return
1259
+ for name, mode, sha in tree_obj.items():
1260
+ path = prefix + name.decode("utf-8", errors="replace")
1261
+ if mode & 0o040000: # Directory
1262
+ try:
1263
+ subtree = (
1264
+ self._repo[sha]
1265
+ if self._repo and hasattr(self._repo, "__getitem__")
1266
+ else None
1267
+ )
1268
+ if subtree is not None:
1269
+ walk_tree(subtree, path + "/")
1270
+ except Exception as e:
1271
+ print(f"DEBUG: Error accessing subtree {sha}: {e}")
1272
+ else: # File
1273
+ entries[path] = (mode, sha)
1274
+ except Exception as e:
1275
+ print(f"DEBUG: Error accessing tree {tree_obj}: {e}")
1276
+
1277
+ walk_tree(tree)
1278
+ return entries
1279
+
1280
+ def _has_file_conflict(self, base_entry, current_entry, remote_entry):
1281
+ """Check if a file has conflicts between the three versions"""
1282
+ # If all three are identical or missing, no conflict
1283
+ if base_entry == current_entry == remote_entry:
1284
+ return False
1285
+
1286
+ # If file doesn't exist in base
1287
+ if base_entry is None:
1288
+ if current_entry is None or remote_entry is None:
1289
+ return False # One side didn't add the file
1290
+ # Both sides added - conflict if different content
1291
+ return current_entry[1] != remote_entry[1] # Compare SHA
1292
+
1293
+ # File exists in base
1294
+ if current_entry is None and remote_entry is None:
1295
+ return False # Both sides deleted - no conflict
1296
+
1297
+ if current_entry is None:
1298
+ return False # Only remote changed - no conflict
1299
+
1300
+ if remote_entry is None:
1301
+ return False # Only current changed - no conflict
1302
+
1303
+ # All three versions exist - check for conflicts
1304
+ current_changed = current_entry[1] != base_entry[1]
1305
+ remote_changed = remote_entry[1] != base_entry[1]
1306
+
1307
+ if not current_changed or not remote_changed:
1308
+ return False # At most one side changed
1309
+
1310
+ # Both sides changed - conflict if they changed differently
1311
+ return current_entry[1] != remote_entry[1]
1312
+
1313
+ def get_ahead_behind_count(
1314
+ self, local_commit: str, remote_commit: str
1315
+ ) -> Tuple[int, int]:
1316
+ """Calculate ahead/behind count between local and remote commits"""
1317
+ if not self._repo:
1318
+ return (0, 0)
1319
+
1320
+ try:
1321
+ # Verify both commits exist
1322
+ try:
1323
+ self._repo[local_commit.encode()]
1324
+ self._repo[remote_commit.encode()]
1325
+ except KeyError:
1326
+ # If remote commit doesn't exist locally, we're behind
1327
+ # This matches the native implementation behavior
1328
+ return (1, 1) # Indicates divergence requiring merge/rebase
1329
+
1330
+ # Count commits that local has but remote doesn't (ahead)
1331
+ try:
1332
+ # Get commits reachable from local but not from remote
1333
+ ahead_walker = self._repo.get_walker(
1334
+ include=[local_commit.encode()], exclude=[remote_commit.encode()]
1335
+ )
1336
+ ahead_count = len(list(ahead_walker))
1337
+ except Exception:
1338
+ ahead_count = 0
1339
+
1340
+ # Count commits that remote has but local doesn't (behind)
1341
+ try:
1342
+ # Get commits reachable from remote but not from local
1343
+ behind_walker = self._repo.get_walker(
1344
+ include=[remote_commit.encode()], exclude=[local_commit.encode()]
1345
+ )
1346
+ behind_count = len(list(behind_walker))
1347
+ except Exception:
1348
+ behind_count = 0
1349
+
1350
+ return (ahead_count, behind_count)
1351
+
1352
+ except Exception:
1353
+ return (0, 0)