@bitseek/hermes-webui 0.1.0-beta.0

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 (233) hide show
  1. package/README.md +213 -0
  2. package/bin/hermes-webui.mjs +588 -0
  3. package/package.json +25 -0
  4. package/scripts/sync-vendor.mjs +74 -0
  5. package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
  6. package/templates/systemd/hermes-webui.service +13 -0
  7. package/templates/windows/hermes-webui-task.ps1 +3 -0
  8. package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
  9. package/vendor/agent-frontend-shell/.dockerignore +7 -0
  10. package/vendor/agent-frontend-shell/.env.docker.example +89 -0
  11. package/vendor/agent-frontend-shell/.env.example +34 -0
  12. package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
  13. package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
  14. package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
  15. package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
  16. package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
  17. package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
  18. package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
  19. package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
  20. package/vendor/agent-frontend-shell/AGENTS.md +80 -0
  21. package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
  22. package/vendor/agent-frontend-shell/BUGS.md +52 -0
  23. package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
  24. package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
  25. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
  26. package/vendor/agent-frontend-shell/DESIGN.md +173 -0
  27. package/vendor/agent-frontend-shell/Dockerfile +91 -0
  28. package/vendor/agent-frontend-shell/LICENSE +21 -0
  29. package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
  30. package/vendor/agent-frontend-shell/README.md +705 -0
  31. package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
  32. package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
  33. package/vendor/agent-frontend-shell/TESTING.md +1932 -0
  34. package/vendor/agent-frontend-shell/THEMES.md +170 -0
  35. package/vendor/agent-frontend-shell/api/__init__.py +1 -0
  36. package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
  37. package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
  38. package/vendor/agent-frontend-shell/api/auth.py +592 -0
  39. package/vendor/agent-frontend-shell/api/background.py +87 -0
  40. package/vendor/agent-frontend-shell/api/clarify.py +238 -0
  41. package/vendor/agent-frontend-shell/api/commands.py +124 -0
  42. package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
  43. package/vendor/agent-frontend-shell/api/config.py +5178 -0
  44. package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
  45. package/vendor/agent-frontend-shell/api/extensions.py +253 -0
  46. package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
  47. package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
  48. package/vendor/agent-frontend-shell/api/goals.py +608 -0
  49. package/vendor/agent-frontend-shell/api/helpers.py +474 -0
  50. package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
  51. package/vendor/agent-frontend-shell/api/metering.py +194 -0
  52. package/vendor/agent-frontend-shell/api/models.py +4210 -0
  53. package/vendor/agent-frontend-shell/api/oauth.py +770 -0
  54. package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
  55. package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
  56. package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
  57. package/vendor/agent-frontend-shell/api/providers.py +2175 -0
  58. package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
  59. package/vendor/agent-frontend-shell/api/rollback.py +320 -0
  60. package/vendor/agent-frontend-shell/api/routes.py +13990 -0
  61. package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
  62. package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
  63. package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
  64. package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
  65. package/vendor/agent-frontend-shell/api/session_events.py +45 -0
  66. package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
  67. package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
  68. package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
  69. package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
  70. package/vendor/agent-frontend-shell/api/startup.py +128 -0
  71. package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
  72. package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
  73. package/vendor/agent-frontend-shell/api/system_health.py +167 -0
  74. package/vendor/agent-frontend-shell/api/terminal.py +410 -0
  75. package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
  76. package/vendor/agent-frontend-shell/api/updates.py +1261 -0
  77. package/vendor/agent-frontend-shell/api/upload.py +322 -0
  78. package/vendor/agent-frontend-shell/api/usage.py +26 -0
  79. package/vendor/agent-frontend-shell/api/workspace.py +867 -0
  80. package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
  81. package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
  82. package/vendor/agent-frontend-shell/bootstrap.py +492 -0
  83. package/vendor/agent-frontend-shell/ctl.sh +427 -0
  84. package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
  85. package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
  86. package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
  87. package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
  88. package/vendor/agent-frontend-shell/docker_init.bash +459 -0
  89. package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
  90. package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
  91. package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
  92. package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
  93. package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
  94. package/vendor/agent-frontend-shell/docs/docker.md +337 -0
  95. package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
  96. package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
  97. package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
  98. package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
  99. package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
  100. package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
  101. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
  102. package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
  103. package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
  104. package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
  105. package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
  106. package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
  107. package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
  108. package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
  109. package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
  110. package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
  111. package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
  112. package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
  113. package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
  114. package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
  115. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
  116. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
  117. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
  118. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
  119. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
  120. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
  121. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
  122. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
  123. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
  124. package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
  125. package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
  126. package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
  127. package/vendor/agent-frontend-shell/extensions/index.js +67 -0
  128. package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
  129. package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
  130. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
  131. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
  132. package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
  133. package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
  134. package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
  135. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
  136. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
  137. package/vendor/agent-frontend-shell/install.sh +63 -0
  138. package/vendor/agent-frontend-shell/mcp_server.py +567 -0
  139. package/vendor/agent-frontend-shell/package.json +12 -0
  140. package/vendor/agent-frontend-shell/pyproject.toml +56 -0
  141. package/vendor/agent-frontend-shell/pytest.ini +3 -0
  142. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  143. package/vendor/agent-frontend-shell/server.py +624 -0
  144. package/vendor/agent-frontend-shell/start.ps1 +210 -0
  145. package/vendor/agent-frontend-shell/start.sh +65 -0
  146. package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
  147. package/vendor/agent-frontend-shell/static/boot.js +1990 -0
  148. package/vendor/agent-frontend-shell/static/commands.js +1402 -0
  149. package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
  150. package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
  151. package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
  152. package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
  153. package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
  154. package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
  155. package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
  156. package/vendor/agent-frontend-shell/static/icons.js +92 -0
  157. package/vendor/agent-frontend-shell/static/index.html +1506 -0
  158. package/vendor/agent-frontend-shell/static/login.js +177 -0
  159. package/vendor/agent-frontend-shell/static/manifest.json +53 -0
  160. package/vendor/agent-frontend-shell/static/messages.js +3521 -0
  161. package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
  162. package/vendor/agent-frontend-shell/static/panels.js +7995 -0
  163. package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
  164. package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
  165. package/vendor/agent-frontend-shell/static/style.css +4774 -0
  166. package/vendor/agent-frontend-shell/static/sw.js +173 -0
  167. package/vendor/agent-frontend-shell/static/terminal.js +632 -0
  168. package/vendor/agent-frontend-shell/static/ui.js +8997 -0
  169. package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
  170. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
  171. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
  172. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  173. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  174. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  175. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  176. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  177. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  178. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  179. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  180. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  181. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  182. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  183. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  184. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  185. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
  186. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
  187. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
  188. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  189. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  190. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  191. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
  192. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
  193. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
  194. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
  195. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
  196. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
  197. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  198. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  199. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  200. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
  201. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
  202. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
  203. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  204. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  205. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  206. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  207. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  208. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  209. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  210. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  211. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  212. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
  213. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
  214. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
  215. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
  216. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
  217. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  218. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
  219. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
  220. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  221. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
  222. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
  223. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  224. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
  225. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
  226. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  227. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  228. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  229. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  230. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
  231. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
  232. package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
  233. package/vendor/agent-frontend-shell/static/workspace.js +680 -0
@@ -0,0 +1,1261 @@
1
+ """Git helpers for the workspace panel.
2
+
3
+ The browser only sends session ids and workspace-relative paths. This module
4
+ resolves the active workspace server-side, scopes paths before they become Git
5
+ pathspecs, and keeps all Git subprocess calls shell-free and bounded.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ import threading
16
+ from contextlib import contextmanager
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Iterable
20
+
21
+ from api.workspace import safe_resolve_ws
22
+
23
+
24
+ GIT_TIMEOUT = 5
25
+ GIT_REMOTE_TIMEOUT = 60
26
+ STATUS_FILE_LIMIT = 500
27
+ DIFF_SIZE_LIMIT = 512 * 1024
28
+ COMMIT_MESSAGE_DIFF_LIMIT = 64 * 1024
29
+ WORKSPACE_GIT_DESTRUCTIVE_ENV = "HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE"
30
+ _GIT_ENV_SCRUB_KEYS = (
31
+ "GIT_DIR",
32
+ "GIT_WORK_TREE",
33
+ "GIT_CONFIG_GLOBAL",
34
+ "GIT_CONFIG_SYSTEM",
35
+ "GIT_CONFIG_COUNT",
36
+ "GIT_CONFIG_PARAMETERS",
37
+ )
38
+ _GIT_ENV_SCRUB_PREFIXES = ("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")
39
+ _HERMES_BRANCH_SWITCH_STASH_PREFIX = "hermes-webui branch switch"
40
+
41
+
42
+ def workspace_git_destructive_enabled() -> bool:
43
+ return os.getenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, "").strip().lower() in {
44
+ "1",
45
+ "true",
46
+ "yes",
47
+ "on",
48
+ }
49
+
50
+
51
+ def _clean_git_env(extra: dict[str, str] | None = None) -> dict[str, str]:
52
+ env = os.environ.copy()
53
+ if extra:
54
+ env.update(extra)
55
+ for key in _GIT_ENV_SCRUB_KEYS:
56
+ env.pop(key, None)
57
+ for key in list(env):
58
+ if key.startswith(_GIT_ENV_SCRUB_PREFIXES):
59
+ env.pop(key, None)
60
+ return env
61
+
62
+
63
+ class GitWorkspaceError(RuntimeError):
64
+ """User-facing Git operation error."""
65
+
66
+ def __init__(self, message: str, code: str = "git_failed"):
67
+ super().__init__(message)
68
+ self.code = code
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class GitContext:
73
+ workspace: Path
74
+ repo_root: Path
75
+ workspace_prefix: str
76
+
77
+
78
+ _LOCKS_GUARD = threading.Lock()
79
+ _OP_LOCKS: dict[str, threading.Lock] = {}
80
+
81
+
82
+ @contextmanager
83
+ def _git_mutation_lock(ctx: GitContext):
84
+ # Key by repo root so sessions in the same repository serialize mutations.
85
+ # Separate worktrees get separate locks; Git still protects shared metadata
86
+ # with its own locks.
87
+ key = str(ctx.repo_root)
88
+ with _LOCKS_GUARD:
89
+ lock = _OP_LOCKS.setdefault(key, threading.Lock())
90
+ if not lock.acquire(timeout=GIT_REMOTE_TIMEOUT):
91
+ raise GitWorkspaceError("Another Git operation is still running", "operation_in_progress")
92
+ try:
93
+ yield
94
+ finally:
95
+ lock.release()
96
+
97
+
98
+ def _classify_git_error(message: str, args: list[str] | None = None) -> str:
99
+ text = (message or "").lower()
100
+ joined = " ".join(args or []).lower()
101
+ if "timed out" in text:
102
+ return "timeout"
103
+ if "not installed" in text or "no such file or directory: 'git'" in text:
104
+ return "missing_git"
105
+ if "not a git repository" in text:
106
+ return "not_a_repo"
107
+ if "outside the workspace" in text or "outside the git repository" in text:
108
+ return "path_outside_workspace"
109
+ if "authentication failed" in text or "permission denied" in text or "could not read username" in text:
110
+ return "auth_failed"
111
+ if "no upstream" in text or "no configured push destination" in text or "has no upstream branch" in text:
112
+ return "no_upstream"
113
+ if (
114
+ "non-fast-forward" in text
115
+ or "fetch first" in text
116
+ or ("rejected" in text and "push" in joined)
117
+ ):
118
+ return "non_fast_forward"
119
+ if "conflict" in text or "unmerged" in text or ("merge" in text and "needs" in text):
120
+ return "conflict"
121
+ if "working tree" in text and ("clean" in text or "dirty" in text):
122
+ return "dirty_worktree"
123
+ if "local changes" in text or "would be overwritten by checkout" in text:
124
+ return "dirty_worktree"
125
+ if "invalid reference" in text or "not a valid" in text or "unknown revision" in text:
126
+ return "invalid_ref"
127
+ if "hook" in text:
128
+ return "hook_failed"
129
+ return "git_failed"
130
+
131
+
132
+ def _run_git(
133
+ ctx_or_cwd: GitContext | Path,
134
+ args: list[str],
135
+ *,
136
+ timeout: int = GIT_TIMEOUT,
137
+ check: bool = False,
138
+ env: dict[str, str] | None = None,
139
+ ) -> subprocess.CompletedProcess[str]:
140
+ cwd = ctx_or_cwd.repo_root if isinstance(ctx_or_cwd, GitContext) else ctx_or_cwd
141
+ run_env = _clean_git_env(env)
142
+ try:
143
+ result = subprocess.run(
144
+ ["git", *args],
145
+ cwd=str(cwd),
146
+ shell=False,
147
+ capture_output=True,
148
+ text=True,
149
+ timeout=timeout,
150
+ env=run_env,
151
+ )
152
+ except subprocess.TimeoutExpired as exc:
153
+ raise GitWorkspaceError("Git command timed out", "timeout") from exc
154
+ except FileNotFoundError as exc:
155
+ raise GitWorkspaceError("Git is not installed or not available on PATH", "missing_git") from exc
156
+ except OSError as exc:
157
+ raise GitWorkspaceError(str(exc), _classify_git_error(str(exc), args)) from exc
158
+ if check and result.returncode != 0:
159
+ message = (result.stderr or result.stdout or "Git command failed").strip()
160
+ raise GitWorkspaceError(message, _classify_git_error(message, args))
161
+ return result
162
+
163
+
164
+ def resolve_git_context(workspace: str | Path) -> GitContext | None:
165
+ ws = Path(workspace).expanduser().resolve()
166
+ result = _run_git(ws, ["rev-parse", "--show-toplevel"], check=False)
167
+ if result.returncode != 0:
168
+ return None
169
+ repo_root = Path(result.stdout.strip()).resolve()
170
+ try:
171
+ prefix = ws.relative_to(repo_root).as_posix()
172
+ except ValueError:
173
+ return None
174
+ return GitContext(workspace=ws, repo_root=repo_root, workspace_prefix="" if prefix == "." else prefix)
175
+
176
+
177
+ def _workspace_pathspec(ctx: GitContext) -> str:
178
+ return ctx.workspace_prefix or "."
179
+
180
+
181
+ def _repo_rel(ctx: GitContext, workspace_rel: str) -> str:
182
+ try:
183
+ target = safe_resolve_ws(ctx.workspace, workspace_rel or ".")
184
+ except ValueError as exc:
185
+ raise GitWorkspaceError(str(exc), "path_outside_workspace") from exc
186
+ try:
187
+ repo_rel = target.relative_to(ctx.repo_root).as_posix()
188
+ except ValueError as exc:
189
+ raise GitWorkspaceError("Path is outside the Git repository", "path_outside_workspace") from exc
190
+ if ctx.workspace_prefix:
191
+ try:
192
+ target.relative_to(ctx.workspace)
193
+ except ValueError as exc:
194
+ raise GitWorkspaceError("Path is outside the workspace", "path_outside_workspace") from exc
195
+ return repo_rel
196
+
197
+
198
+ def _workspace_rel(ctx: GitContext, repo_rel: str) -> str | None:
199
+ repo_rel = repo_rel.replace("\\", "/")
200
+ if not ctx.workspace_prefix:
201
+ return repo_rel
202
+ prefix = ctx.workspace_prefix.rstrip("/") + "/"
203
+ if repo_rel == ctx.workspace_prefix:
204
+ return "."
205
+ if repo_rel.startswith(prefix):
206
+ return repo_rel[len(prefix) :]
207
+ return None
208
+
209
+
210
+ def _empty_status() -> dict:
211
+ return {
212
+ "changed": 0,
213
+ "staged": 0,
214
+ "unstaged": 0,
215
+ "untracked": 0,
216
+ "conflicts": 0,
217
+ }
218
+
219
+
220
+ def _status_code(xy: str, *, untracked: bool = False, renamed: bool = False) -> str:
221
+ if untracked:
222
+ return "??"
223
+ if xy in {"DD", "AU", "UD", "UA", "DU", "AA", "UU"}:
224
+ return xy
225
+ if renamed:
226
+ return "R"
227
+ for ch in xy:
228
+ if ch in "MADRCUT":
229
+ return ch
230
+ return xy.strip(".") or "M"
231
+
232
+
233
+ def _parse_numstat(text: str, ctx: GitContext) -> dict[str, tuple[int, int, bool]]:
234
+ stats: dict[str, tuple[int, int, bool]] = {}
235
+ for line in text.splitlines():
236
+ parts = line.split("\t", 2)
237
+ if len(parts) < 3:
238
+ continue
239
+ raw_add, raw_del, raw_path = parts
240
+ binary = raw_add == "-" or raw_del == "-"
241
+ additions = 0 if binary else int(raw_add or "0")
242
+ deletions = 0 if binary else int(raw_del or "0")
243
+ workspace_path = _workspace_rel(ctx, raw_path)
244
+ if workspace_path is None:
245
+ continue
246
+ stats[workspace_path] = (additions, deletions, binary)
247
+ return stats
248
+
249
+
250
+ def _parse_path_list(text: str, ctx: GitContext) -> set[str]:
251
+ paths: set[str] = set()
252
+ for raw_path in text.split("\0"):
253
+ if not raw_path:
254
+ continue
255
+ workspace_path = _workspace_rel(ctx, raw_path)
256
+ if workspace_path is not None:
257
+ paths.add(workspace_path)
258
+ return paths
259
+
260
+
261
+ def _collect_diff_paths(ctx: GitContext, cached: bool, *, ignore_cr_at_eol: bool = True) -> set[str] | None:
262
+ args = ["diff", "--name-only", "-z"]
263
+ if ignore_cr_at_eol:
264
+ args.append("--ignore-cr-at-eol")
265
+ if cached:
266
+ args.append("--cached")
267
+ args.extend(["--", _workspace_pathspec(ctx)])
268
+ result = _run_git(ctx, args, check=False)
269
+ if result.returncode != 0:
270
+ return None
271
+ return _parse_path_list(result.stdout, ctx)
272
+
273
+
274
+ def _collect_numstat(
275
+ ctx: GitContext,
276
+ cached: bool,
277
+ *,
278
+ ignore_cr_at_eol: bool = True,
279
+ ) -> dict[str, tuple[int, int, bool]]:
280
+ args = ["diff", "--numstat"]
281
+ if ignore_cr_at_eol:
282
+ args.append("--ignore-cr-at-eol")
283
+ if cached:
284
+ args.append("--cached")
285
+ args.extend(["--", _workspace_pathspec(ctx)])
286
+ result = _run_git(ctx, args, check=False)
287
+ if result.returncode != 0:
288
+ return {}
289
+ return _parse_numstat(result.stdout, ctx)
290
+
291
+
292
+ def _count_untracked_file(path: Path) -> tuple[int, int, bool]:
293
+ try:
294
+ if not path.is_file() or path.stat().st_size > DIFF_SIZE_LIMIT:
295
+ return 0, 0, False
296
+ except OSError:
297
+ return 0, 0, False
298
+ try:
299
+ data = path.read_bytes()
300
+ except OSError:
301
+ return 0, 0, False
302
+ if b"\0" in data:
303
+ return 0, 0, True
304
+ try:
305
+ text = data.decode("utf-8")
306
+ except UnicodeDecodeError:
307
+ return 0, 0, True
308
+ return len(text.splitlines()) or (1 if text else 0), 0, False
309
+
310
+
311
+ def git_status(workspace: str | Path) -> dict:
312
+ ctx = resolve_git_context(workspace)
313
+ if ctx is None:
314
+ return {"is_git": False}
315
+
316
+ result = _run_git(
317
+ ctx,
318
+ [
319
+ "status",
320
+ "--porcelain=v2",
321
+ "-z",
322
+ "--branch",
323
+ "--ignored=matching",
324
+ "--untracked-files=all",
325
+ "--",
326
+ _workspace_pathspec(ctx),
327
+ ],
328
+ check=True,
329
+ )
330
+ staged_stats = _collect_numstat(ctx, cached=True)
331
+ unstaged_stats = _collect_numstat(ctx, cached=False)
332
+ staged_raw_stats = _collect_numstat(ctx, cached=True, ignore_cr_at_eol=False)
333
+ unstaged_raw_stats = _collect_numstat(ctx, cached=False, ignore_cr_at_eol=False)
334
+ staged_diff_paths = _collect_diff_paths(ctx, cached=True)
335
+ unstaged_diff_paths = _collect_diff_paths(ctx, cached=False)
336
+
337
+ branch = ""
338
+ upstream = ""
339
+ ahead = 0
340
+ behind = 0
341
+ files: dict[str, dict] = {}
342
+ filtered_noise = {"filemode_only": 0, "crlf_only": 0}
343
+ tokens = result.stdout.split("\0")
344
+ i = 0
345
+ truncated = False
346
+ while i < len(tokens):
347
+ rec = tokens[i]
348
+ i += 1
349
+ if not rec:
350
+ continue
351
+ if rec.startswith("# "):
352
+ parts = rec.split(" ", 2)
353
+ if len(parts) >= 3 and parts[1] == "branch.head":
354
+ branch = "" if parts[2] == "(detached)" else parts[2]
355
+ elif len(parts) >= 3 and parts[1] == "branch.upstream":
356
+ upstream = parts[2]
357
+ elif len(parts) >= 3 and parts[1] == "branch.ab":
358
+ for bit in parts[2].split():
359
+ if bit.startswith("+") and bit[1:].isdigit():
360
+ ahead = int(bit[1:])
361
+ elif bit.startswith("-") and bit[1:].isdigit():
362
+ behind = int(bit[1:])
363
+ continue
364
+
365
+ old_path = None
366
+ renamed = False
367
+ if rec.startswith("? "):
368
+ xy = "??"
369
+ repo_path = rec[2:]
370
+ untracked = True
371
+ ignored = False
372
+ elif rec.startswith("! "):
373
+ xy = "!!"
374
+ repo_path = rec[2:]
375
+ untracked = False
376
+ ignored = True
377
+ elif rec.startswith("1 "):
378
+ parts = rec.split(" ", 8)
379
+ if len(parts) < 9:
380
+ continue
381
+ xy = parts[1]
382
+ repo_path = parts[8]
383
+ untracked = False
384
+ ignored = False
385
+ elif rec.startswith("2 "):
386
+ parts = rec.split(" ", 9)
387
+ if len(parts) < 10:
388
+ continue
389
+ xy = parts[1]
390
+ repo_path = parts[9]
391
+ if i < len(tokens):
392
+ old_path = tokens[i]
393
+ i += 1
394
+ renamed = True
395
+ untracked = False
396
+ ignored = False
397
+ elif rec.startswith("u "):
398
+ parts = rec.split(" ", 10)
399
+ if len(parts) < 11:
400
+ continue
401
+ xy = parts[1]
402
+ repo_path = parts[10]
403
+ untracked = False
404
+ ignored = False
405
+ else:
406
+ continue
407
+
408
+ workspace_path = _workspace_rel(ctx, repo_path)
409
+ if workspace_path is None:
410
+ continue
411
+ old_workspace_path = _workspace_rel(ctx, old_path) if old_path else None
412
+ x = xy[0] if xy else "."
413
+ y = xy[1] if len(xy) > 1 else "."
414
+ conflict = xy in {"DD", "AU", "UD", "UA", "DU", "AA", "UU"} or rec.startswith("u ")
415
+ additions, deletions, binary = 0, 0, False
416
+ for source in (staged_stats, unstaged_stats):
417
+ if workspace_path in source:
418
+ a, d, b = source[workspace_path]
419
+ additions += a
420
+ deletions += d
421
+ binary = binary or b
422
+ if untracked:
423
+ additions, deletions, binary = _count_untracked_file(ctx.workspace / workspace_path)
424
+
425
+ staged = (x not in {".", "?"}) and not untracked
426
+ unstaged = (y not in {".", " "}) and not untracked
427
+ if staged and staged_diff_paths is not None and not renamed:
428
+ raw_staged = staged
429
+ staged = workspace_path in staged_diff_paths or (
430
+ old_workspace_path is not None and old_workspace_path in staged_diff_paths
431
+ )
432
+ if raw_staged and not staged:
433
+ if workspace_path in staged_raw_stats or (
434
+ old_workspace_path is not None and old_workspace_path in staged_raw_stats
435
+ ):
436
+ filtered_noise["crlf_only"] += 1
437
+ else:
438
+ filtered_noise["filemode_only"] += 1
439
+ if unstaged and unstaged_diff_paths is not None and not renamed:
440
+ raw_unstaged = unstaged
441
+ unstaged = workspace_path in unstaged_diff_paths or (
442
+ old_workspace_path is not None and old_workspace_path in unstaged_diff_paths
443
+ )
444
+ if raw_unstaged and not unstaged:
445
+ if workspace_path in unstaged_raw_stats or (
446
+ old_workspace_path is not None and old_workspace_path in unstaged_raw_stats
447
+ ):
448
+ filtered_noise["crlf_only"] += 1
449
+ else:
450
+ filtered_noise["filemode_only"] += 1
451
+ if ignored:
452
+ files[workspace_path] = {
453
+ "path": workspace_path,
454
+ "old_path": None,
455
+ "workspace_path": workspace_path,
456
+ "status": "Ignored",
457
+ "staged": False,
458
+ "unstaged": False,
459
+ "untracked": False,
460
+ "ignored": True,
461
+ "conflict": False,
462
+ "additions": 0,
463
+ "deletions": 0,
464
+ "binary": False,
465
+ }
466
+ if len(files) >= STATUS_FILE_LIMIT:
467
+ truncated = True
468
+ break
469
+ continue
470
+
471
+ if not (staged or unstaged or untracked or conflict or renamed):
472
+ continue
473
+ if not (untracked or conflict or renamed or binary) and additions == 0 and deletions == 0:
474
+ filtered_noise["crlf_only"] += 1
475
+ continue
476
+
477
+ files[workspace_path] = {
478
+ "path": workspace_path,
479
+ "old_path": old_workspace_path,
480
+ "workspace_path": workspace_path,
481
+ "status": _status_code(xy, untracked=untracked, renamed=renamed),
482
+ "staged": staged,
483
+ "unstaged": unstaged,
484
+ "untracked": untracked,
485
+ "ignored": False,
486
+ "conflict": conflict,
487
+ "additions": additions,
488
+ "deletions": deletions,
489
+ "binary": binary,
490
+ }
491
+ if len(files) >= STATUS_FILE_LIMIT:
492
+ truncated = True
493
+ break
494
+
495
+ file_list = sorted(files.values(), key=lambda f: (f["path"].lower()))
496
+ totals = _empty_status()
497
+ for item in file_list:
498
+ if item.get("ignored"):
499
+ continue
500
+ if item["staged"]:
501
+ totals["staged"] += 1
502
+ if item["unstaged"]:
503
+ totals["unstaged"] += 1
504
+ if item["untracked"]:
505
+ totals["untracked"] += 1
506
+ if item["conflict"]:
507
+ totals["conflicts"] += 1
508
+ totals["changed"] = sum(1 for item in file_list if not item.get("ignored"))
509
+
510
+ if not branch:
511
+ branch = (_run_git(ctx, ["rev-parse", "--short", "HEAD"], check=False).stdout or "").strip()
512
+ return {
513
+ "is_git": True,
514
+ "branch": branch or "HEAD",
515
+ "upstream": upstream,
516
+ "ahead": ahead,
517
+ "behind": behind,
518
+ "totals": totals,
519
+ "files": file_list,
520
+ "truncated": truncated,
521
+ "noise_filtering": {
522
+ **filtered_noise,
523
+ "active": any(filtered_noise.values()),
524
+ },
525
+ }
526
+
527
+
528
+ def _branch_ahead_behind(ctx: GitContext, branch: str, upstream: str) -> tuple[int, int]:
529
+ if not upstream:
530
+ return 0, 0
531
+ result = _run_git(ctx, ["rev-list", "--left-right", "--count", f"{branch}...{upstream}"], check=False)
532
+ if result.returncode != 0:
533
+ return 0, 0
534
+ parts = result.stdout.strip().split()
535
+ if len(parts) != 2:
536
+ return 0, 0
537
+ try:
538
+ return int(parts[0]), int(parts[1])
539
+ except ValueError:
540
+ return 0, 0
541
+
542
+
543
+ def _for_each_ref(ctx: GitContext, ref_prefix: str) -> list[dict]:
544
+ fmt = (
545
+ "%(refname)%00%(refname:short)%00%(upstream:short)%00%(objectname:short)%00"
546
+ "%(committerdate:unix)%00%(committerdate:relative)%00%(authorname)%00%(subject)"
547
+ )
548
+ result = _run_git(ctx, ["for-each-ref", f"--format={fmt}", ref_prefix], check=True)
549
+ refs = []
550
+ for line in result.stdout.splitlines():
551
+ full_name, name, upstream, sha, updated, updated_relative, author, subject = (
552
+ line.split("\0") + ["", "", "", "", "", "", "", ""]
553
+ )[:8]
554
+ if not name or full_name.endswith("/HEAD") or name.endswith("/HEAD"):
555
+ continue
556
+ if ref_prefix == "refs/remotes" and "/" not in name:
557
+ continue
558
+ item = {
559
+ "name": name,
560
+ "sha": sha,
561
+ "updated": int(updated) if str(updated).isdigit() else 0,
562
+ "updated_relative": updated_relative,
563
+ "author": author,
564
+ "subject": subject,
565
+ }
566
+ if upstream:
567
+ ahead, behind = _branch_ahead_behind(ctx, name, upstream)
568
+ item.update({"upstream": upstream, "ahead": ahead, "behind": behind})
569
+ else:
570
+ item.update({"upstream": "", "ahead": 0, "behind": 0})
571
+ refs.append(item)
572
+ return sorted(refs, key=lambda item: item["name"].lower())
573
+
574
+
575
+ def git_branches(workspace: str | Path) -> dict:
576
+ ctx = resolve_git_context(workspace)
577
+ if ctx is None:
578
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
579
+ head_name = _run_git(ctx, ["branch", "--show-current"], check=True).stdout.strip()
580
+ detached = not bool(head_name)
581
+ head_sha = _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip()
582
+ status = git_status(workspace)
583
+ local = _for_each_ref(ctx, "refs/heads")
584
+ remote = _for_each_ref(ctx, "refs/remotes")
585
+ return {
586
+ "is_git": True,
587
+ "current": head_name or head_sha or "HEAD",
588
+ "detached": detached,
589
+ "head": head_sha,
590
+ "local": local,
591
+ "remote": remote,
592
+ "upstream": status.get("upstream", ""),
593
+ "ahead": status.get("ahead", 0),
594
+ "behind": status.get("behind", 0),
595
+ }
596
+
597
+
598
+ def _validate_local_branch(ctx: GitContext, ref: str) -> str:
599
+ ref = str(ref or "").strip()
600
+ if not ref:
601
+ raise GitWorkspaceError("Branch name is required", "invalid_ref")
602
+ _run_git(ctx, ["show-ref", "--verify", f"refs/heads/{ref}"], check=True)
603
+ return ref
604
+
605
+
606
+ def _validate_remote_branch(ctx: GitContext, ref: str) -> str:
607
+ ref = str(ref or "").strip()
608
+ if not ref:
609
+ raise GitWorkspaceError("Remote branch name is required", "invalid_ref")
610
+ _run_git(ctx, ["show-ref", "--verify", f"refs/remotes/{ref}"], check=True)
611
+ return ref
612
+
613
+
614
+ def _validate_checkout_start(ctx: GitContext, ref: str) -> str:
615
+ ref = str(ref or "HEAD").strip() or "HEAD"
616
+ result = _run_git(ctx, ["rev-parse", "--verify", f"{ref}^{{commit}}"], check=False)
617
+ if result.returncode != 0:
618
+ raise GitWorkspaceError("Invalid checkout reference", "invalid_ref")
619
+ return ref
620
+
621
+
622
+ def _validate_new_branch_name(ctx: GitContext, name: str) -> str:
623
+ name = str(name or "").strip()
624
+ if not name:
625
+ raise GitWorkspaceError("New branch name is required", "invalid_ref")
626
+ result = _run_git(ctx, ["check-ref-format", "--branch", name], check=False)
627
+ if result.returncode != 0:
628
+ raise GitWorkspaceError("Invalid branch name", "invalid_ref")
629
+ exists = _run_git(ctx, ["show-ref", "--verify", f"refs/heads/{name}"], check=False)
630
+ if exists.returncode == 0:
631
+ raise GitWorkspaceError("A local branch with that name already exists", "invalid_ref")
632
+ return name
633
+
634
+
635
+ def _dirty_worktree(ctx: GitContext) -> bool:
636
+ result = _run_git(ctx, ["status", "--porcelain=v2", "--untracked-files=all"], check=True)
637
+ return bool(result.stdout.strip())
638
+
639
+
640
+ def _current_checkout_label(ctx: GitContext) -> str:
641
+ branch = _run_git(ctx, ["branch", "--show-current"], check=False).stdout.strip()
642
+ if branch:
643
+ return branch
644
+ return _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip() or "HEAD"
645
+
646
+
647
+ def _stash_subject_parts(subject: str) -> tuple[str, str] | None:
648
+ subject = str(subject or "").strip()
649
+ if not subject.startswith("On ") or ": " not in subject:
650
+ return None
651
+ branch, message = subject[3:].split(": ", 1)
652
+ branch = branch.strip()
653
+ message = message.strip()
654
+ if not branch or not message.startswith(_HERMES_BRANCH_SWITCH_STASH_PREFIX):
655
+ return None
656
+ return branch, message
657
+
658
+
659
+ def _hermes_branch_switch_stashes(ctx: GitContext) -> list[dict]:
660
+ result = _run_git(ctx, ["stash", "list", "--format=%gd%x00%gs"], check=False)
661
+ if result.returncode != 0:
662
+ return []
663
+ stashes = []
664
+ for line in result.stdout.splitlines():
665
+ try:
666
+ ref, subject = line.split("\0", 1)
667
+ except ValueError:
668
+ continue
669
+ parts = _stash_subject_parts(subject)
670
+ if not parts:
671
+ continue
672
+ branch, message = parts
673
+ stashes.append({"ref": ref, "branch": branch, "message": message})
674
+ return stashes
675
+
676
+
677
+ def _restore_branch_switch_stash_locked(ctx: GitContext, branch: str) -> dict:
678
+ if _dirty_worktree(ctx):
679
+ return {}
680
+ for item in _hermes_branch_switch_stashes(ctx):
681
+ if item.get("branch") != branch:
682
+ continue
683
+ result = _run_git(ctx, ["stash", "pop", "--index", item["ref"]], check=False)
684
+ if result.returncode == 0:
685
+ return {"restored_stash": item}
686
+ return {
687
+ "restore_failed": True,
688
+ "restore_error": (result.stderr or result.stdout or "Git stash restore failed").strip(),
689
+ "restore_stash": item,
690
+ }
691
+ return {}
692
+
693
+
694
+ def _validate_checkout_request_locked(
695
+ ctx: GitContext,
696
+ ref: str,
697
+ mode: str,
698
+ new_branch: str | None,
699
+ ) -> None:
700
+ if mode == "local":
701
+ _validate_local_branch(ctx, ref)
702
+ return
703
+ if mode in {"new", "create"}:
704
+ _validate_new_branch_name(ctx, new_branch or ref)
705
+ _validate_checkout_start(ctx, ref if (new_branch and ref and ref != new_branch) else "HEAD")
706
+ return
707
+ if mode == "remote":
708
+ remote_ref = _validate_remote_branch(ctx, ref)
709
+ branch_name = str(new_branch or remote_ref.split("/", 1)[-1]).strip()
710
+ exists = _run_git(ctx, ["show-ref", "--verify", f"refs/heads/{branch_name}"], check=False)
711
+ if exists.returncode != 0:
712
+ _validate_new_branch_name(ctx, branch_name)
713
+ return
714
+ if mode in {"detached", "detach"}:
715
+ _validate_checkout_start(ctx, ref)
716
+ return
717
+ raise GitWorkspaceError("Unsupported checkout mode", "invalid_ref")
718
+
719
+
720
+ def _perform_checkout_locked(
721
+ ctx: GitContext,
722
+ workspace: str | Path,
723
+ ref: str,
724
+ mode: str,
725
+ new_branch: str | None,
726
+ track: bool,
727
+ ) -> subprocess.CompletedProcess[str]:
728
+ if mode == "local":
729
+ target = _validate_local_branch(ctx, ref)
730
+ return _run_git(ctx, ["switch", target], check=True)
731
+ if mode in {"new", "create"}:
732
+ branch = _validate_new_branch_name(ctx, new_branch or ref)
733
+ start_ref = _validate_checkout_start(ctx, ref if (new_branch and ref and ref != new_branch) else "HEAD")
734
+ return _run_git(ctx, ["switch", "-c", branch, start_ref], check=True)
735
+ if mode == "remote":
736
+ remote_ref = _validate_remote_branch(ctx, ref)
737
+ branch_name = str(new_branch or remote_ref.split("/", 1)[-1]).strip()
738
+ exists = _run_git(ctx, ["show-ref", "--verify", f"refs/heads/{branch_name}"], check=False)
739
+ if exists.returncode == 0:
740
+ result = _run_git(ctx, ["switch", branch_name], check=True)
741
+ if track:
742
+ _run_git(ctx, ["branch", "--set-upstream-to", remote_ref, branch_name], check=False)
743
+ return result
744
+ branch = _validate_new_branch_name(ctx, branch_name)
745
+ args = ["switch", "-c", branch]
746
+ if track:
747
+ args.append("--track")
748
+ args.append(remote_ref)
749
+ return _run_git(ctx, args, check=True)
750
+ if mode in {"detached", "detach"}:
751
+ target = _validate_checkout_start(ctx, ref)
752
+ return _run_git(ctx, ["switch", "--detach", target], check=True)
753
+ raise GitWorkspaceError("Unsupported checkout mode", "invalid_ref")
754
+
755
+
756
+ def git_checkout(
757
+ workspace: str | Path,
758
+ ref: str,
759
+ mode: str,
760
+ new_branch: str | None = None,
761
+ track: bool = False,
762
+ dirty_mode: str = "block",
763
+ ) -> dict:
764
+ ctx = resolve_git_context(workspace)
765
+ if ctx is None:
766
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
767
+ mode = str(mode or "local").strip().lower()
768
+ dirty_mode = str(dirty_mode or "block").strip().lower()
769
+ if dirty_mode != "block":
770
+ raise GitWorkspaceError("Only dirty_mode=block is supported for branch checkout", "dirty_worktree")
771
+ with _git_mutation_lock(ctx):
772
+ _validate_checkout_request_locked(ctx, ref, mode, new_branch)
773
+ if _dirty_worktree(ctx):
774
+ raise GitWorkspaceError(
775
+ "Checkout blocked because the Git worktree has uncommitted changes",
776
+ "dirty_worktree",
777
+ )
778
+ result = _perform_checkout_locked(ctx, workspace, ref, mode, new_branch, track)
779
+ status = git_status(workspace)
780
+ branches = git_branches(workspace)
781
+ return {
782
+ "ok": True,
783
+ "message": _remote_message(result),
784
+ "current_branch": branches.get("current"),
785
+ "status": status,
786
+ "branches": branches,
787
+ }
788
+
789
+
790
+ def git_stash_and_checkout(
791
+ workspace: str | Path,
792
+ ref: str,
793
+ mode: str,
794
+ new_branch: str | None = None,
795
+ track: bool = False,
796
+ ) -> dict:
797
+ ctx = resolve_git_context(workspace)
798
+ if ctx is None:
799
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
800
+ mode = str(mode or "local").strip().lower()
801
+ target_label = str(new_branch or ref or "HEAD").strip() or "HEAD"
802
+ stash_name = f"{_HERMES_BRANCH_SWITCH_STASH_PREFIX} to {target_label}".strip()
803
+ restored: dict = {}
804
+ with _git_mutation_lock(ctx):
805
+ _validate_checkout_request_locked(ctx, ref, mode, new_branch)
806
+ stashed = False
807
+ if _dirty_worktree(ctx):
808
+ stash_result = _run_git(ctx, ["stash", "push", "-u", "-m", stash_name], check=True)
809
+ stash_text = _remote_message(stash_result)
810
+ stashed = "No local changes to save" not in stash_text
811
+ try:
812
+ result = _perform_checkout_locked(ctx, workspace, ref, mode, new_branch, track)
813
+ except Exception:
814
+ if stashed:
815
+ _run_git(ctx, ["stash", "pop", "--index", "stash@{0}"], check=False)
816
+ raise
817
+ current_branch = _current_checkout_label(ctx)
818
+ restored = _restore_branch_switch_stash_locked(ctx, current_branch)
819
+ status = git_status(workspace)
820
+ branches = git_branches(workspace)
821
+ return {
822
+ "ok": True,
823
+ "message": _remote_message(result),
824
+ "stash_name": stash_name if stashed else "",
825
+ "stashed": stashed,
826
+ "restored_stash": restored.get("restored_stash"),
827
+ "restore_failed": bool(restored.get("restore_failed")),
828
+ "restore_error": restored.get("restore_error", ""),
829
+ "restore_stash": restored.get("restore_stash"),
830
+ "current_branch": branches.get("current"),
831
+ "status": status,
832
+ "branches": branches,
833
+ }
834
+
835
+
836
+ def _diff_stats(diff_text: str) -> tuple[int, int]:
837
+ additions = deletions = 0
838
+ for line in diff_text.splitlines():
839
+ if line.startswith("+++") or line.startswith("---"):
840
+ continue
841
+ if line.startswith("+"):
842
+ additions += 1
843
+ elif line.startswith("-"):
844
+ deletions += 1
845
+ return additions, deletions
846
+
847
+
848
+ def _synthetic_untracked_diff(path: Path, label: str) -> dict:
849
+ try:
850
+ if not path.is_file():
851
+ raise GitWorkspaceError("Path is not a file")
852
+ if path.stat().st_size > DIFF_SIZE_LIMIT:
853
+ return {
854
+ "binary": False,
855
+ "too_large": True,
856
+ "diff": "",
857
+ "additions": 0,
858
+ "deletions": 0,
859
+ }
860
+ except OSError as exc:
861
+ raise GitWorkspaceError(str(exc)) from exc
862
+ try:
863
+ data = path.read_bytes()
864
+ except OSError as exc:
865
+ raise GitWorkspaceError(str(exc)) from exc
866
+ if b"\0" in data:
867
+ return {"binary": True, "too_large": False, "diff": "", "additions": 0, "deletions": 0}
868
+ try:
869
+ text = data.decode("utf-8")
870
+ except UnicodeDecodeError:
871
+ return {"binary": True, "too_large": False, "diff": "", "additions": 0, "deletions": 0}
872
+ lines = text.splitlines()
873
+ diff_lines = list(
874
+ difflib.unified_diff([], lines, fromfile="/dev/null", tofile=f"b/{label}", lineterm="")
875
+ )
876
+ diff = "\n".join(diff_lines) + ("\n" if diff_lines else "")
877
+ too_large = len(diff.encode("utf-8", errors="replace")) > DIFF_SIZE_LIMIT
878
+ if too_large:
879
+ diff = diff[:DIFF_SIZE_LIMIT]
880
+ additions, deletions = _diff_stats(diff)
881
+ return {
882
+ "binary": False,
883
+ "too_large": too_large,
884
+ "diff": diff,
885
+ "additions": additions,
886
+ "deletions": deletions,
887
+ }
888
+
889
+
890
+ def git_diff(workspace: str | Path, path: str, kind: str = "unstaged") -> dict:
891
+ ctx = resolve_git_context(workspace)
892
+ if ctx is None:
893
+ raise GitWorkspaceError("Workspace is not a Git repository")
894
+ if kind not in {"unstaged", "staged"}:
895
+ raise GitWorkspaceError("kind must be staged or unstaged")
896
+ repo_rel = _repo_rel(ctx, path)
897
+ workspace_rel = _workspace_rel(ctx, repo_rel) or path
898
+
899
+ status = git_status(workspace)
900
+ file_state = next((f for f in status.get("files", []) if f.get("path") == workspace_rel), None)
901
+ if kind == "unstaged" and file_state and file_state.get("untracked"):
902
+ payload = _synthetic_untracked_diff(ctx.workspace / workspace_rel, workspace_rel)
903
+ return {"path": workspace_rel, "kind": kind, **payload}
904
+
905
+ args = ["diff", "--no-ext-diff", "--unified=3"]
906
+ if kind == "staged":
907
+ args.append("--cached")
908
+ args.extend(["--", repo_rel])
909
+ result = _run_git(ctx, args, check=True)
910
+ diff = result.stdout
911
+ binary = "Binary files " in diff or "GIT binary patch" in diff
912
+ too_large = len(diff.encode("utf-8", errors="replace")) > DIFF_SIZE_LIMIT
913
+ if too_large:
914
+ diff = diff[:DIFF_SIZE_LIMIT]
915
+ additions, deletions = _diff_stats(diff)
916
+ return {
917
+ "path": workspace_rel,
918
+ "kind": kind,
919
+ "binary": binary,
920
+ "too_large": too_large,
921
+ "additions": additions,
922
+ "deletions": deletions,
923
+ "diff": "" if binary else diff,
924
+ }
925
+
926
+
927
+ def _clean_paths(paths: Iterable[str]) -> list[str]:
928
+ cleaned = []
929
+ for path in paths:
930
+ value = str(path or "").strip()
931
+ if value and value not in cleaned:
932
+ cleaned.append(value)
933
+ if not cleaned:
934
+ raise GitWorkspaceError("At least one path is required")
935
+ return cleaned
936
+
937
+
938
+ def _pathspecs(ctx: GitContext, paths: Iterable[str]) -> list[str]:
939
+ return [_repo_rel(ctx, path) for path in _clean_paths(paths)]
940
+
941
+
942
+ def git_stage(workspace: str | Path, paths: Iterable[str]) -> dict:
943
+ ctx = resolve_git_context(workspace)
944
+ if ctx is None:
945
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
946
+ with _git_mutation_lock(ctx):
947
+ _run_git(ctx, ["add", "--", *_pathspecs(ctx, paths)], check=True)
948
+ return git_status(workspace)
949
+
950
+
951
+ def git_unstage(workspace: str | Path, paths: Iterable[str]) -> dict:
952
+ ctx = resolve_git_context(workspace)
953
+ if ctx is None:
954
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
955
+ specs = _pathspecs(ctx, paths)
956
+ with _git_mutation_lock(ctx):
957
+ result = _run_git(ctx, ["restore", "--staged", "--", *specs], check=False)
958
+ if result.returncode != 0:
959
+ _run_git(ctx, ["reset", "HEAD", "--", *specs], check=True)
960
+ return git_status(workspace)
961
+
962
+
963
+ def git_discard(workspace: str | Path, paths: Iterable[str], *, delete_untracked: bool = False) -> dict:
964
+ ctx = resolve_git_context(workspace)
965
+ if ctx is None:
966
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
967
+ with _git_mutation_lock(ctx):
968
+ status = git_status(workspace)
969
+ by_path = {f["path"]: f for f in status.get("files", [])}
970
+ for path in _clean_paths(paths):
971
+ repo_rel = _repo_rel(ctx, path)
972
+ workspace_rel = _workspace_rel(ctx, repo_rel) or path
973
+ state = by_path.get(workspace_rel) or by_path.get(workspace_rel.rstrip("/") + "/")
974
+ if state and state.get("conflict"):
975
+ raise GitWorkspaceError("Conflicted files cannot be discarded from this panel", "conflict")
976
+ if state and state.get("untracked"):
977
+ if not delete_untracked:
978
+ raise GitWorkspaceError("Untracked files require delete_untracked=true")
979
+ target = safe_resolve_ws(ctx.workspace, workspace_rel)
980
+ if target.is_dir():
981
+ shutil.rmtree(target)
982
+ else:
983
+ target.unlink(missing_ok=True)
984
+ continue
985
+ _run_git(ctx, ["restore", "--worktree", "--", repo_rel], check=True)
986
+ return git_status(workspace)
987
+
988
+
989
+ COMMIT_MESSAGE_SYSTEM_PROMPT = """When writing commit messages, PR titles, or PR descriptions:
990
+
991
+ - Inspect the staged diff before suggesting a commit message.
992
+ - Do not use vague subjects like "update", "improve", "refine", "misc changes", "fix stuff", or "various changes".
993
+ - For large commits, write a concise subject plus a short body with 2-5 bullets summarizing the main areas changed.
994
+ - The subject should describe the actual user-facing result or bug fixed, not just broad implementation activity.
995
+ - Keep wording short, clear, and natural.
996
+ - Never mention AI, Cursor, Zed, agents, or similar tooling in commits, branch names, PR titles, or PR descriptions.
997
+ - Never add your own thoughts or questions into the commit message, the commit message is definitive in nature.
998
+
999
+ Return only the commit message text. Do not wrap it in Markdown fences.
1000
+ """.strip()
1001
+
1002
+
1003
+ def _staged_diff_text(ctx: GitContext) -> tuple[str, bool]:
1004
+ result = _run_git(
1005
+ ctx,
1006
+ [
1007
+ "diff",
1008
+ "--cached",
1009
+ "--no-ext-diff",
1010
+ "--unified=3",
1011
+ "--",
1012
+ _workspace_pathspec(ctx),
1013
+ ],
1014
+ check=True,
1015
+ )
1016
+ diff = result.stdout or ""
1017
+ encoded = diff.encode("utf-8", errors="replace")
1018
+ if len(encoded) <= COMMIT_MESSAGE_DIFF_LIMIT:
1019
+ return diff, False
1020
+ return encoded[:COMMIT_MESSAGE_DIFF_LIMIT].decode("utf-8", errors="replace"), True
1021
+
1022
+
1023
+ def _selected_temp_index_env(ctx: GitContext, specs: list[str]) -> tuple[dict[str, str], str]:
1024
+ fd, index_path = tempfile.mkstemp(prefix="hermes-webui-git-index-")
1025
+ os.close(fd)
1026
+ Path(index_path).unlink(missing_ok=True)
1027
+ env = {"GIT_INDEX_FILE": index_path}
1028
+ try:
1029
+ head = _run_git(ctx, ["rev-parse", "--verify", "HEAD"], check=False, env=env)
1030
+ if head.returncode == 0:
1031
+ _run_git(ctx, ["read-tree", "HEAD"], check=True, env=env)
1032
+ else:
1033
+ _run_git(ctx, ["read-tree", "--empty"], check=True, env=env)
1034
+ _run_git(ctx, ["add", "-A", "--", *specs], check=True, env=env)
1035
+ return env, index_path
1036
+ except Exception:
1037
+ Path(index_path).unlink(missing_ok=True)
1038
+ raise
1039
+
1040
+
1041
+ def _selected_files(ctx: GitContext, paths: Iterable[str]) -> tuple[list[str], list[str], list[dict]]:
1042
+ requested = _clean_paths(paths)
1043
+ requested_specs = [_repo_rel(ctx, path) for path in requested]
1044
+ workspace_paths = [_workspace_rel(ctx, spec) or path for spec, path in zip(requested_specs, requested)]
1045
+ status = git_status(ctx.workspace)
1046
+ by_path = {f["path"]: f for f in status.get("files", [])}
1047
+ specs: list[str] = []
1048
+ selected = []
1049
+ for path, repo_rel in zip(workspace_paths, requested_specs):
1050
+ state = by_path.get(path)
1051
+ if not state:
1052
+ continue
1053
+ if state.get("conflict"):
1054
+ raise GitWorkspaceError("Resolve conflicts before committing selected files", "conflict")
1055
+ if state.get("staged") or state.get("unstaged") or state.get("untracked"):
1056
+ selected.append(state)
1057
+ for spec in (repo_rel, _repo_rel(ctx, state["old_path"]) if state.get("old_path") else ""):
1058
+ if spec and spec not in specs:
1059
+ specs.append(spec)
1060
+ if len(selected) != len(workspace_paths):
1061
+ raise GitWorkspaceError("Selected paths have no committable changes")
1062
+ return specs, workspace_paths, selected
1063
+
1064
+
1065
+ def _selected_diff_text(ctx: GitContext, specs: list[str]) -> tuple[str, bool]:
1066
+ env, index_path = _selected_temp_index_env(ctx, specs)
1067
+ try:
1068
+ result = _run_git(
1069
+ ctx,
1070
+ ["diff", "--cached", "--no-ext-diff", "--unified=3", "--", *specs],
1071
+ check=True,
1072
+ env=env,
1073
+ )
1074
+ diff = result.stdout or ""
1075
+ encoded = diff.encode("utf-8", errors="replace")
1076
+ if len(encoded) <= COMMIT_MESSAGE_DIFF_LIMIT:
1077
+ return diff, False
1078
+ return encoded[:COMMIT_MESSAGE_DIFF_LIMIT].decode("utf-8", errors="replace"), True
1079
+ finally:
1080
+ Path(index_path).unlink(missing_ok=True)
1081
+
1082
+
1083
+ def selected_commit_message_prompt(workspace: str | Path, paths: Iterable[str]) -> dict:
1084
+ ctx = resolve_git_context(workspace)
1085
+ if ctx is None:
1086
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
1087
+ specs, _workspace_paths, selected_files = _selected_files(ctx, paths)
1088
+ diff, truncated = _selected_diff_text(ctx, specs)
1089
+ if not diff.strip():
1090
+ raise GitWorkspaceError("No selected diff is available")
1091
+ status = git_status(workspace)
1092
+ file_lines = []
1093
+ for item in selected_files[:80]:
1094
+ stats = (
1095
+ "binary"
1096
+ if item.get("binary")
1097
+ else f"+{item.get('additions') or 0} -{item.get('deletions') or 0}"
1098
+ )
1099
+ file_lines.append(f"- {item.get('status') or 'M'} {item.get('path')} ({stats})")
1100
+ if len(selected_files) > 80:
1101
+ file_lines.append(f"- ... {len(selected_files) - 80} more selected file(s)")
1102
+ user_prompt = (
1103
+ "Write a commit message for the selected Git diff below.\n\n"
1104
+ f"Branch: {status.get('branch') or 'HEAD'}\n"
1105
+ f"Selected files ({len(selected_files)}):\n"
1106
+ + "\n".join(file_lines)
1107
+ + (
1108
+ "\n\nDiff was truncated for size; summarize only what is visible.\n"
1109
+ if truncated
1110
+ else "\n"
1111
+ )
1112
+ + "\nSelected diff:\n```diff\n"
1113
+ + diff
1114
+ + "\n```"
1115
+ )
1116
+ return {
1117
+ "system_prompt": COMMIT_MESSAGE_SYSTEM_PROMPT,
1118
+ "user_prompt": user_prompt,
1119
+ "truncated": truncated,
1120
+ "status": status,
1121
+ }
1122
+
1123
+
1124
+ def staged_commit_message_prompt(workspace: str | Path) -> dict:
1125
+ ctx = resolve_git_context(workspace)
1126
+ if ctx is None:
1127
+ raise GitWorkspaceError("Workspace is not a Git repository")
1128
+ status = git_status(workspace)
1129
+ if int((status.get("totals") or {}).get("staged") or 0) <= 0:
1130
+ raise GitWorkspaceError("Stage changes before generating a commit message")
1131
+ diff, truncated = _staged_diff_text(ctx)
1132
+ if not diff.strip():
1133
+ raise GitWorkspaceError("No staged diff is available")
1134
+ staged_files = [f for f in status.get("files", []) if f.get("staged")]
1135
+ file_lines = []
1136
+ for item in staged_files[:80]:
1137
+ stats = (
1138
+ "binary"
1139
+ if item.get("binary")
1140
+ else f"+{item.get('additions') or 0} -{item.get('deletions') or 0}"
1141
+ )
1142
+ file_lines.append(f"- {item.get('status') or 'M'} {item.get('path')} ({stats})")
1143
+ if len(staged_files) > 80:
1144
+ file_lines.append(f"- ... {len(staged_files) - 80} more staged file(s)")
1145
+ user_prompt = (
1146
+ "Write a commit message for the staged Git diff below.\n\n"
1147
+ f"Branch: {status.get('branch') or 'HEAD'}\n"
1148
+ f"Staged files ({len(staged_files)}):\n"
1149
+ + "\n".join(file_lines)
1150
+ + (
1151
+ "\n\nDiff was truncated for size; summarize only what is visible.\n"
1152
+ if truncated
1153
+ else "\n"
1154
+ )
1155
+ + "\nStaged diff:\n```diff\n"
1156
+ + diff
1157
+ + "\n```"
1158
+ )
1159
+ return {
1160
+ "system_prompt": COMMIT_MESSAGE_SYSTEM_PROMPT,
1161
+ "user_prompt": user_prompt,
1162
+ "truncated": truncated,
1163
+ "status": status,
1164
+ }
1165
+
1166
+
1167
+ def clean_generated_commit_message(message: str) -> str:
1168
+ text = str(message or "").strip()
1169
+ if text.startswith("```"):
1170
+ lines = text.splitlines()
1171
+ if lines and lines[0].startswith("```"):
1172
+ lines = lines[1:]
1173
+ if lines and lines[-1].strip() == "```":
1174
+ lines = lines[:-1]
1175
+ text = "\n".join(lines).strip()
1176
+ if (text.startswith('"') and text.endswith('"')) or (
1177
+ text.startswith("'") and text.endswith("'")
1178
+ ):
1179
+ text = text[1:-1].strip()
1180
+ return text
1181
+
1182
+
1183
+ def git_commit(workspace: str | Path, message: str) -> dict:
1184
+ msg = str(message or "").strip()
1185
+ if not msg:
1186
+ raise GitWorkspaceError("Commit message is required")
1187
+ ctx = resolve_git_context(workspace)
1188
+ if ctx is None:
1189
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
1190
+ with _git_mutation_lock(ctx):
1191
+ _run_git(ctx, ["commit", "-m", msg], timeout=10, check=True)
1192
+ sha = _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip()
1193
+ return {"ok": True, "commit": sha, "status": git_status(workspace)}
1194
+
1195
+
1196
+ def git_commit_selected(workspace: str | Path, message: str, paths: Iterable[str]) -> dict:
1197
+ msg = str(message or "").strip()
1198
+ if not msg:
1199
+ raise GitWorkspaceError("Commit message is required")
1200
+ ctx = resolve_git_context(workspace)
1201
+ if ctx is None:
1202
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
1203
+ with _git_mutation_lock(ctx):
1204
+ specs, workspace_paths, _selected_files_list = _selected_files(ctx, paths)
1205
+ env, index_path = _selected_temp_index_env(ctx, specs)
1206
+ try:
1207
+ quiet = _run_git(ctx, ["diff", "--cached", "--quiet", "--", *specs], check=False, env=env)
1208
+ if quiet.returncode == 0:
1209
+ raise GitWorkspaceError("Selected paths have no committable changes")
1210
+ _run_git(ctx, ["commit", "-m", msg], timeout=10, check=True, env=env)
1211
+ _run_git(ctx, ["reset", "-q", "HEAD", "--", *specs], check=True)
1212
+ finally:
1213
+ Path(index_path).unlink(missing_ok=True)
1214
+ sha = _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip()
1215
+ return {"ok": True, "commit": sha, "paths": workspace_paths, "status": git_status(workspace)}
1216
+
1217
+
1218
+ def _branch_name(ctx: GitContext) -> str:
1219
+ branch = _run_git(ctx, ["branch", "--show-current"], check=True).stdout.strip()
1220
+ if not branch:
1221
+ raise GitWorkspaceError("Cannot push from a detached HEAD")
1222
+ return branch
1223
+
1224
+
1225
+ def _remote_message(result: subprocess.CompletedProcess[str]) -> str:
1226
+ return (result.stdout or result.stderr or "").strip()
1227
+
1228
+
1229
+ def git_fetch(workspace: str | Path) -> dict:
1230
+ ctx = resolve_git_context(workspace)
1231
+ if ctx is None:
1232
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
1233
+ with _git_mutation_lock(ctx):
1234
+ result = _run_git(ctx, ["fetch", "--prune"], timeout=GIT_REMOTE_TIMEOUT, check=True)
1235
+ return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}
1236
+
1237
+
1238
+ def git_pull(workspace: str | Path) -> dict:
1239
+ ctx = resolve_git_context(workspace)
1240
+ if ctx is None:
1241
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
1242
+ with _git_mutation_lock(ctx):
1243
+ result = _run_git(ctx, ["pull", "--ff-only"], timeout=GIT_REMOTE_TIMEOUT, check=True)
1244
+ return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}
1245
+
1246
+
1247
+ def git_push(workspace: str | Path) -> dict:
1248
+ ctx = resolve_git_context(workspace)
1249
+ if ctx is None:
1250
+ raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
1251
+ with _git_mutation_lock(ctx):
1252
+ status = git_status(workspace)
1253
+ args = ["push"]
1254
+ if not status.get("upstream"):
1255
+ branch = _branch_name(ctx)
1256
+ remotes = _run_git(ctx, ["remote"], check=True).stdout.split()
1257
+ if "origin" not in remotes:
1258
+ raise GitWorkspaceError("No upstream branch or origin remote is configured", "no_upstream")
1259
+ args.extend(["-u", "origin", branch])
1260
+ result = _run_git(ctx, args, timeout=GIT_REMOTE_TIMEOUT, check=True)
1261
+ return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}