@ilya-lesikov/pi-pi 0.1.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 (271) hide show
  1. package/3p/pi-ask-user/index.ts +1835 -0
  2. package/3p/pi-ask-user/package.json +50 -0
  3. package/3p/pi-ask-user/single-select-layout.ts +203 -0
  4. package/3p/pi-lsp/extensions/lsp/client.ts +542 -0
  5. package/3p/pi-lsp/extensions/lsp/config.ts +135 -0
  6. package/3p/pi-lsp/extensions/lsp/effects/command.ts +42 -0
  7. package/3p/pi-lsp/extensions/lsp/effects/filesystem.ts +50 -0
  8. package/3p/pi-lsp/extensions/lsp/effects/runtime.ts +21 -0
  9. package/3p/pi-lsp/extensions/lsp/errors.ts +113 -0
  10. package/3p/pi-lsp/extensions/lsp/formatting.ts +315 -0
  11. package/3p/pi-lsp/extensions/lsp/index.ts +231 -0
  12. package/3p/pi-lsp/extensions/lsp/protocol.ts +209 -0
  13. package/3p/pi-lsp/extensions/lsp/retry.ts +39 -0
  14. package/3p/pi-lsp/extensions/lsp/tools/programs.ts +293 -0
  15. package/3p/pi-lsp/extensions/lsp/tools.ts +89 -0
  16. package/3p/pi-lsp/extensions/lsp/types.ts +243 -0
  17. package/3p/pi-lsp/package.json +54 -0
  18. package/3p/pi-plannotator/AGENTS.md +583 -0
  19. package/3p/pi-plannotator/apps/pi-extension/README.md +228 -0
  20. package/3p/pi-plannotator/apps/pi-extension/assistant-message.ts +128 -0
  21. package/3p/pi-plannotator/apps/pi-extension/config.test.ts +166 -0
  22. package/3p/pi-plannotator/apps/pi-extension/config.ts +318 -0
  23. package/3p/pi-plannotator/apps/pi-extension/current-pi-session.ts +147 -0
  24. package/3p/pi-plannotator/apps/pi-extension/index.ts +1279 -0
  25. package/3p/pi-plannotator/apps/pi-extension/package.json +56 -0
  26. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.test.ts +13 -0
  27. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +616 -0
  28. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +342 -0
  29. package/3p/pi-plannotator/apps/pi-extension/plannotator.json +12 -0
  30. package/3p/pi-plannotator/apps/pi-extension/server/agent-jobs.ts +515 -0
  31. package/3p/pi-plannotator/apps/pi-extension/server/ai-runtime.ts +169 -0
  32. package/3p/pi-plannotator/apps/pi-extension/server/annotations.ts +85 -0
  33. package/3p/pi-plannotator/apps/pi-extension/server/external-annotations.ts +189 -0
  34. package/3p/pi-plannotator/apps/pi-extension/server/handlers.ts +210 -0
  35. package/3p/pi-plannotator/apps/pi-extension/server/helpers.ts +78 -0
  36. package/3p/pi-plannotator/apps/pi-extension/server/ide.ts +46 -0
  37. package/3p/pi-plannotator/apps/pi-extension/server/integrations.ts +195 -0
  38. package/3p/pi-plannotator/apps/pi-extension/server/network.test.ts +158 -0
  39. package/3p/pi-plannotator/apps/pi-extension/server/network.ts +268 -0
  40. package/3p/pi-plannotator/apps/pi-extension/server/pr.ts +126 -0
  41. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +64 -0
  42. package/3p/pi-plannotator/apps/pi-extension/server/reference.ts +362 -0
  43. package/3p/pi-plannotator/apps/pi-extension/server/serverAnnotate.ts +199 -0
  44. package/3p/pi-plannotator/apps/pi-extension/server/serverPlan.ts +505 -0
  45. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +1229 -0
  46. package/3p/pi-plannotator/apps/pi-extension/server/vcs.ts +121 -0
  47. package/3p/pi-plannotator/apps/pi-extension/server.test.ts +966 -0
  48. package/3p/pi-plannotator/apps/pi-extension/server.ts +44 -0
  49. package/3p/pi-plannotator/apps/pi-extension/tool-scope.test.ts +88 -0
  50. package/3p/pi-plannotator/apps/pi-extension/tool-scope.ts +39 -0
  51. package/3p/pi-plannotator/apps/pi-extension/tsconfig.json +16 -0
  52. package/3p/pi-plannotator/apps/pi-extension/vendor.sh +48 -0
  53. package/3p/pi-plannotator/package.json +53 -0
  54. package/3p/pi-plannotator/packages/ai/ai.test.ts +1407 -0
  55. package/3p/pi-plannotator/packages/ai/base-session.ts +94 -0
  56. package/3p/pi-plannotator/packages/ai/context.ts +250 -0
  57. package/3p/pi-plannotator/packages/ai/endpoints.ts +326 -0
  58. package/3p/pi-plannotator/packages/ai/index.ts +105 -0
  59. package/3p/pi-plannotator/packages/ai/package.json +21 -0
  60. package/3p/pi-plannotator/packages/ai/provider.ts +103 -0
  61. package/3p/pi-plannotator/packages/ai/providers/claude-agent-sdk.ts +447 -0
  62. package/3p/pi-plannotator/packages/ai/providers/codex-sdk.ts +430 -0
  63. package/3p/pi-plannotator/packages/ai/providers/command-path.ts +115 -0
  64. package/3p/pi-plannotator/packages/ai/providers/opencode-sdk.ts +546 -0
  65. package/3p/pi-plannotator/packages/ai/providers/pi-events.ts +110 -0
  66. package/3p/pi-plannotator/packages/ai/providers/pi-sdk-node.ts +425 -0
  67. package/3p/pi-plannotator/packages/ai/providers/pi-sdk.ts +469 -0
  68. package/3p/pi-plannotator/packages/ai/session-manager.ts +195 -0
  69. package/3p/pi-plannotator/packages/ai/tsconfig.json +15 -0
  70. package/3p/pi-plannotator/packages/ai/types.ts +379 -0
  71. package/3p/pi-plannotator/packages/server/agent-jobs.ts +538 -0
  72. package/3p/pi-plannotator/packages/server/agent-review-message.test.ts +135 -0
  73. package/3p/pi-plannotator/packages/server/agent-review-message.ts +243 -0
  74. package/3p/pi-plannotator/packages/server/ai-runtime.ts +108 -0
  75. package/3p/pi-plannotator/packages/server/annotate.ts +401 -0
  76. package/3p/pi-plannotator/packages/server/browser.test.ts +87 -0
  77. package/3p/pi-plannotator/packages/server/browser.ts +245 -0
  78. package/3p/pi-plannotator/packages/server/claude-review.ts +352 -0
  79. package/3p/pi-plannotator/packages/server/code-nav.ts +73 -0
  80. package/3p/pi-plannotator/packages/server/codex-review-schema.json +41 -0
  81. package/3p/pi-plannotator/packages/server/codex-review.ts +330 -0
  82. package/3p/pi-plannotator/packages/server/config.ts +8 -0
  83. package/3p/pi-plannotator/packages/server/draft.ts +1 -0
  84. package/3p/pi-plannotator/packages/server/editor-annotations.ts +76 -0
  85. package/3p/pi-plannotator/packages/server/external-annotations.test.ts +18 -0
  86. package/3p/pi-plannotator/packages/server/external-annotations.ts +207 -0
  87. package/3p/pi-plannotator/packages/server/git.ts +141 -0
  88. package/3p/pi-plannotator/packages/server/goal-setup.test.ts +55 -0
  89. package/3p/pi-plannotator/packages/server/goal-setup.ts +248 -0
  90. package/3p/pi-plannotator/packages/server/ide.ts +43 -0
  91. package/3p/pi-plannotator/packages/server/image.test.ts +63 -0
  92. package/3p/pi-plannotator/packages/server/image.ts +66 -0
  93. package/3p/pi-plannotator/packages/server/index.ts +648 -0
  94. package/3p/pi-plannotator/packages/server/integrations.test.ts +172 -0
  95. package/3p/pi-plannotator/packages/server/integrations.ts +214 -0
  96. package/3p/pi-plannotator/packages/server/jj.test.ts +69 -0
  97. package/3p/pi-plannotator/packages/server/jj.ts +86 -0
  98. package/3p/pi-plannotator/packages/server/p4.ts +417 -0
  99. package/3p/pi-plannotator/packages/server/package.json +40 -0
  100. package/3p/pi-plannotator/packages/server/path-utils.ts +18 -0
  101. package/3p/pi-plannotator/packages/server/pr.ts +146 -0
  102. package/3p/pi-plannotator/packages/server/project.test.ts +115 -0
  103. package/3p/pi-plannotator/packages/server/project.ts +45 -0
  104. package/3p/pi-plannotator/packages/server/reference-handlers.ts +404 -0
  105. package/3p/pi-plannotator/packages/server/remote.test.ts +150 -0
  106. package/3p/pi-plannotator/packages/server/remote.ts +74 -0
  107. package/3p/pi-plannotator/packages/server/repo.ts +80 -0
  108. package/3p/pi-plannotator/packages/server/resolve-file.test.ts +310 -0
  109. package/3p/pi-plannotator/packages/server/review-workspace.test.ts +1032 -0
  110. package/3p/pi-plannotator/packages/server/review-workspace.ts +48 -0
  111. package/3p/pi-plannotator/packages/server/review.ts +1318 -0
  112. package/3p/pi-plannotator/packages/server/sessions.ts +111 -0
  113. package/3p/pi-plannotator/packages/server/share-url.ts +53 -0
  114. package/3p/pi-plannotator/packages/server/shared-handlers.test.ts +43 -0
  115. package/3p/pi-plannotator/packages/server/shared-handlers.ts +177 -0
  116. package/3p/pi-plannotator/packages/server/storage.test.ts +176 -0
  117. package/3p/pi-plannotator/packages/server/storage.ts +17 -0
  118. package/3p/pi-plannotator/packages/server/tour/tour-review.test.ts +146 -0
  119. package/3p/pi-plannotator/packages/server/tour/tour-review.ts +604 -0
  120. package/3p/pi-plannotator/packages/server/tsconfig.json +15 -0
  121. package/3p/pi-plannotator/packages/server/vcs.test.ts +48 -0
  122. package/3p/pi-plannotator/packages/server/vcs.ts +80 -0
  123. package/3p/pi-plannotator/packages/shared/agent-jobs.ts +132 -0
  124. package/3p/pi-plannotator/packages/shared/agents.ts +53 -0
  125. package/3p/pi-plannotator/packages/shared/annotate-args.test.ts +386 -0
  126. package/3p/pi-plannotator/packages/shared/annotate-args.ts +107 -0
  127. package/3p/pi-plannotator/packages/shared/at-reference.test.ts +99 -0
  128. package/3p/pi-plannotator/packages/shared/at-reference.ts +52 -0
  129. package/3p/pi-plannotator/packages/shared/checklist.ts +52 -0
  130. package/3p/pi-plannotator/packages/shared/code-file.test.ts +112 -0
  131. package/3p/pi-plannotator/packages/shared/code-file.ts +41 -0
  132. package/3p/pi-plannotator/packages/shared/code-nav.test.ts +515 -0
  133. package/3p/pi-plannotator/packages/shared/code-nav.ts +436 -0
  134. package/3p/pi-plannotator/packages/shared/compress.ts +51 -0
  135. package/3p/pi-plannotator/packages/shared/config.ts +262 -0
  136. package/3p/pi-plannotator/packages/shared/crypto.test.ts +172 -0
  137. package/3p/pi-plannotator/packages/shared/crypto.ts +97 -0
  138. package/3p/pi-plannotator/packages/shared/data-dir.ts +42 -0
  139. package/3p/pi-plannotator/packages/shared/diff-paths.test.ts +30 -0
  140. package/3p/pi-plannotator/packages/shared/diff-paths.ts +137 -0
  141. package/3p/pi-plannotator/packages/shared/draft.ts +64 -0
  142. package/3p/pi-plannotator/packages/shared/external-annotation.ts +397 -0
  143. package/3p/pi-plannotator/packages/shared/extract-code-paths.test.ts +59 -0
  144. package/3p/pi-plannotator/packages/shared/extract-code-paths.ts +66 -0
  145. package/3p/pi-plannotator/packages/shared/favicon.ts +5 -0
  146. package/3p/pi-plannotator/packages/shared/feedback-templates.test.ts +65 -0
  147. package/3p/pi-plannotator/packages/shared/feedback-templates.ts +29 -0
  148. package/3p/pi-plannotator/packages/shared/goal-setup.test.ts +231 -0
  149. package/3p/pi-plannotator/packages/shared/goal-setup.ts +336 -0
  150. package/3p/pi-plannotator/packages/shared/html-to-markdown.test.ts +62 -0
  151. package/3p/pi-plannotator/packages/shared/html-to-markdown.ts +32 -0
  152. package/3p/pi-plannotator/packages/shared/improvement-hooks.test.ts +135 -0
  153. package/3p/pi-plannotator/packages/shared/improvement-hooks.ts +115 -0
  154. package/3p/pi-plannotator/packages/shared/integrations-common.ts +243 -0
  155. package/3p/pi-plannotator/packages/shared/jj-core.test.ts +236 -0
  156. package/3p/pi-plannotator/packages/shared/jj-core.ts +433 -0
  157. package/3p/pi-plannotator/packages/shared/package.json +55 -0
  158. package/3p/pi-plannotator/packages/shared/pfm-reminder.test.ts +88 -0
  159. package/3p/pi-plannotator/packages/shared/pfm-reminder.ts +80 -0
  160. package/3p/pi-plannotator/packages/shared/pr-github.ts +661 -0
  161. package/3p/pi-plannotator/packages/shared/pr-gitlab.test.ts +202 -0
  162. package/3p/pi-plannotator/packages/shared/pr-gitlab.ts +620 -0
  163. package/3p/pi-plannotator/packages/shared/pr-provider.test.ts +266 -0
  164. package/3p/pi-plannotator/packages/shared/pr-provider.ts +123 -0
  165. package/3p/pi-plannotator/packages/shared/pr-stack.test.ts +104 -0
  166. package/3p/pi-plannotator/packages/shared/pr-stack.ts +194 -0
  167. package/3p/pi-plannotator/packages/shared/pr-types.ts +326 -0
  168. package/3p/pi-plannotator/packages/shared/project.ts +71 -0
  169. package/3p/pi-plannotator/packages/shared/prompts-integration.test.ts +421 -0
  170. package/3p/pi-plannotator/packages/shared/prompts.test.ts +504 -0
  171. package/3p/pi-plannotator/packages/shared/prompts.ts +247 -0
  172. package/3p/pi-plannotator/packages/shared/reference-common.ts +87 -0
  173. package/3p/pi-plannotator/packages/shared/repo.ts +71 -0
  174. package/3p/pi-plannotator/packages/shared/resolve-file.test.ts +113 -0
  175. package/3p/pi-plannotator/packages/shared/resolve-file.ts +509 -0
  176. package/3p/pi-plannotator/packages/shared/review-args.test.ts +64 -0
  177. package/3p/pi-plannotator/packages/shared/review-args.ts +85 -0
  178. package/3p/pi-plannotator/packages/shared/review-core.test.ts +286 -0
  179. package/3p/pi-plannotator/packages/shared/review-core.ts +895 -0
  180. package/3p/pi-plannotator/packages/shared/review-workspace-node.ts +230 -0
  181. package/3p/pi-plannotator/packages/shared/review-workspace.ts +436 -0
  182. package/3p/pi-plannotator/packages/shared/semantic-diff-types.ts +76 -0
  183. package/3p/pi-plannotator/packages/shared/semantic-diff.test.ts +322 -0
  184. package/3p/pi-plannotator/packages/shared/semantic-diff.ts +520 -0
  185. package/3p/pi-plannotator/packages/shared/storage.ts +378 -0
  186. package/3p/pi-plannotator/packages/shared/tour.ts +61 -0
  187. package/3p/pi-plannotator/packages/shared/tsconfig.json +15 -0
  188. package/3p/pi-plannotator/packages/shared/types.ts +29 -0
  189. package/3p/pi-plannotator/packages/shared/url-to-markdown.test.ts +177 -0
  190. package/3p/pi-plannotator/packages/shared/url-to-markdown.ts +351 -0
  191. package/3p/pi-plannotator/packages/shared/vcs-core.test.ts +332 -0
  192. package/3p/pi-plannotator/packages/shared/vcs-core.ts +482 -0
  193. package/3p/pi-plannotator/packages/shared/worktree-pool.test.ts +162 -0
  194. package/3p/pi-plannotator/packages/shared/worktree-pool.ts +103 -0
  195. package/3p/pi-plannotator/packages/shared/worktree.ts +119 -0
  196. package/3p/pi-subagents/package.json +50 -0
  197. package/3p/pi-subagents/src/agent-manager.ts +413 -0
  198. package/3p/pi-subagents/src/agent-runner.ts +502 -0
  199. package/3p/pi-subagents/src/agent-types.ts +248 -0
  200. package/3p/pi-subagents/src/context.ts +58 -0
  201. package/3p/pi-subagents/src/cross-extension-rpc.ts +135 -0
  202. package/3p/pi-subagents/src/custom-agents.ts +137 -0
  203. package/3p/pi-subagents/src/default-agents.ts +144 -0
  204. package/3p/pi-subagents/src/env.ts +33 -0
  205. package/3p/pi-subagents/src/group-join.ts +141 -0
  206. package/3p/pi-subagents/src/index.ts +1811 -0
  207. package/3p/pi-subagents/src/invocation-config.ts +40 -0
  208. package/3p/pi-subagents/src/memory.ts +165 -0
  209. package/3p/pi-subagents/src/model-resolver.ts +81 -0
  210. package/3p/pi-subagents/src/output-file.ts +77 -0
  211. package/3p/pi-subagents/src/prompts.ts +85 -0
  212. package/3p/pi-subagents/src/skill-loader.ts +79 -0
  213. package/3p/pi-subagents/src/types.ts +111 -0
  214. package/3p/pi-subagents/src/ui/agent-widget.ts +496 -0
  215. package/3p/pi-subagents/src/ui/conversation-viewer.ts +250 -0
  216. package/3p/pi-subagents/src/worktree.ts +162 -0
  217. package/3p/pi-tasks/package.json +52 -0
  218. package/3p/pi-tasks/src/auto-clear.ts +91 -0
  219. package/3p/pi-tasks/src/index.ts +1150 -0
  220. package/3p/pi-tasks/src/process-tracker.ts +140 -0
  221. package/3p/pi-tasks/src/reminder-cadence.ts +90 -0
  222. package/3p/pi-tasks/src/task-store.ts +324 -0
  223. package/3p/pi-tasks/src/tasks-config.ts +27 -0
  224. package/3p/pi-tasks/src/types.ts +40 -0
  225. package/3p/pi-tasks/src/ui/settings-menu.ts +152 -0
  226. package/3p/pi-tasks/src/ui/task-widget.ts +296 -0
  227. package/AGENTS.md +28 -0
  228. package/LICENSE +201 -0
  229. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +77 -0
  230. package/extensions/orchestrator/agents/code-reviewer.ts +89 -0
  231. package/extensions/orchestrator/agents/explore.ts +33 -0
  232. package/extensions/orchestrator/agents/librarian.ts +43 -0
  233. package/extensions/orchestrator/agents/plan-reviewer.ts +75 -0
  234. package/extensions/orchestrator/agents/planner.ts +61 -0
  235. package/extensions/orchestrator/agents/registry.ts +156 -0
  236. package/extensions/orchestrator/agents/task.ts +50 -0
  237. package/extensions/orchestrator/agents/tool-routing.ts +84 -0
  238. package/extensions/orchestrator/ast-search.ts +85 -0
  239. package/extensions/orchestrator/cbm.ts +330 -0
  240. package/extensions/orchestrator/command-handlers.test.ts +163 -0
  241. package/extensions/orchestrator/command-handlers.ts +116 -0
  242. package/extensions/orchestrator/commands.test.ts +81 -0
  243. package/extensions/orchestrator/commands.ts +75 -0
  244. package/extensions/orchestrator/config.test.ts +221 -0
  245. package/extensions/orchestrator/config.ts +230 -0
  246. package/extensions/orchestrator/context.test.ts +293 -0
  247. package/extensions/orchestrator/context.ts +206 -0
  248. package/extensions/orchestrator/event-handlers.test.ts +190 -0
  249. package/extensions/orchestrator/event-handlers.ts +1416 -0
  250. package/extensions/orchestrator/exa.ts +104 -0
  251. package/extensions/orchestrator/flant-infra.ts +486 -0
  252. package/extensions/orchestrator/index.ts +80 -0
  253. package/extensions/orchestrator/integration.test.ts +1214 -0
  254. package/extensions/orchestrator/orchestrator.test.ts +252 -0
  255. package/extensions/orchestrator/orchestrator.ts +538 -0
  256. package/extensions/orchestrator/phases/brainstorm.test.ts +15 -0
  257. package/extensions/orchestrator/phases/brainstorm.ts +273 -0
  258. package/extensions/orchestrator/phases/implementation.ts +40 -0
  259. package/extensions/orchestrator/phases/machine.test.ts +293 -0
  260. package/extensions/orchestrator/phases/machine.ts +209 -0
  261. package/extensions/orchestrator/phases/planning.ts +255 -0
  262. package/extensions/orchestrator/phases/review.ts +193 -0
  263. package/extensions/orchestrator/plannotator.ts +56 -0
  264. package/extensions/orchestrator/pp-menu.ts +866 -0
  265. package/extensions/orchestrator/state.test.ts +343 -0
  266. package/extensions/orchestrator/state.ts +237 -0
  267. package/extensions/orchestrator/validate-artifacts.test.ts +88 -0
  268. package/extensions/orchestrator/validate-artifacts.ts +272 -0
  269. package/extensions/orchestrator/vendor.d.ts +26 -0
  270. package/package.json +73 -0
  271. package/scripts/postinstall.sh +18 -0
@@ -0,0 +1,895 @@
1
+ /**
2
+ * Runtime-agnostic code-review core shared by Bun runtimes and Pi.
3
+ *
4
+ * Pi consumes a build-time copy of this file so its published package stays
5
+ * self-contained while review diff logic remains sourced from one module.
6
+ */
7
+
8
+ import { resolve as resolvePath } from "node:path";
9
+
10
+ export const JJ_TRUNK_REVSET = "trunk()";
11
+
12
+ export type DiffType =
13
+ | "uncommitted"
14
+ | "staged"
15
+ | "unstaged"
16
+ | "last-commit"
17
+ | "jj-current"
18
+ | "jj-last"
19
+ | "jj-line"
20
+ | "jj-all"
21
+ | "jj-evolog"
22
+ | "branch"
23
+ | "merge-base"
24
+ | "all"
25
+ | `worktree:${string}`
26
+ | "p4-default"
27
+ | `p4-changelist:${string}`;
28
+
29
+ export interface DiffOption {
30
+ id: string;
31
+ label: string;
32
+ }
33
+
34
+ export interface WorktreeInfo {
35
+ path: string;
36
+ branch: string | null;
37
+ head: string;
38
+ }
39
+
40
+ export interface AvailableBranches {
41
+ local: string[];
42
+ remote: string[];
43
+ }
44
+
45
+ export interface CompareTargetPickerCopy {
46
+ rowLabel: string;
47
+ triggerLabel: string;
48
+ triggerTitlePrefix: string;
49
+ searchPlaceholder: string;
50
+ emptyText: string;
51
+ localGroupLabel: string;
52
+ remoteGroupLabel: string;
53
+ }
54
+
55
+ export interface CompareTargetConfig {
56
+ diffTypes: string[];
57
+ fallback: string;
58
+ picker: CompareTargetPickerCopy;
59
+ }
60
+
61
+ export interface RepositoryContext {
62
+ displayFallback?: string;
63
+ }
64
+
65
+ export interface JjEvoLogEntry {
66
+ /** Short commit ID (12 hex chars) */
67
+ commitId: string;
68
+ /** First line of the commit message */
69
+ description: string;
70
+ /** Human-readable age string, e.g. "2 hours ago" */
71
+ age?: string;
72
+ }
73
+
74
+ export interface RecentCommit {
75
+ /** Full SHA — sent back as the diff base. */
76
+ sha: string;
77
+ /** Abbreviated SHA for display. */
78
+ shortSha: string;
79
+ /** First line of the commit message. */
80
+ subject: string;
81
+ /** Human-readable age string, e.g. "2 hours ago". */
82
+ relativeDate: string;
83
+ /** Committer-name; shown after the subject in the picker. */
84
+ author: string;
85
+ }
86
+
87
+ export interface GitContext {
88
+ currentBranch: string;
89
+ defaultBranch: string;
90
+ diffOptions: DiffOption[];
91
+ worktrees: WorktreeInfo[];
92
+ availableBranches: AvailableBranches;
93
+ compareTarget?: CompareTargetConfig;
94
+ repository?: RepositoryContext;
95
+ cwd?: string;
96
+ vcsType?: "git" | "jj" | "p4";
97
+ /** Evolution log entries for the current jj change (jj only). */
98
+ jjEvologs?: JjEvoLogEntry[];
99
+ /** HEAD ancestry, newest first. Powers the commit-based baseline picker (#709). */
100
+ recentCommits?: RecentCommit[];
101
+ }
102
+
103
+ export interface DiffResult {
104
+ patch: string;
105
+ label: string;
106
+ error?: string;
107
+ }
108
+
109
+ export interface GitCommandResult {
110
+ stdout: string;
111
+ stderr: string;
112
+ exitCode: number;
113
+ }
114
+
115
+ export interface ReviewGitRuntime {
116
+ runGit: (
117
+ args: string[],
118
+ options?: { cwd?: string; timeoutMs?: number },
119
+ ) => Promise<GitCommandResult>;
120
+ readTextFile: (path: string) => Promise<string | null>;
121
+ }
122
+
123
+ export interface GitDiffOptions {
124
+ hideWhitespace?: boolean;
125
+ }
126
+
127
+ export function parseRemoteBookmark(target: string): { name: string; remote: string } | null {
128
+ const at = target.lastIndexOf("@");
129
+ if (at <= 0 || at === target.length - 1) return null;
130
+ return { name: target.slice(0, at), remote: target.slice(at + 1) };
131
+ }
132
+
133
+ export function jjCompareTargetRevset(target: string): string {
134
+ const remoteBookmark = parseRemoteBookmark(target);
135
+ if (remoteBookmark) {
136
+ return `remote_bookmarks(exact:${quoteJjString(remoteBookmark.name)}, exact:${quoteJjString(remoteBookmark.remote)})`;
137
+ }
138
+
139
+ const localBookmark = parseJjBookmarkName(target);
140
+ return localBookmark ? `bookmarks(exact:${quoteJjString(localBookmark)})` : target;
141
+ }
142
+
143
+ export function jjLineBaseRevset(target: string): string {
144
+ const compareTarget = jjCompareTargetRevset(target);
145
+ return `heads(::@ & ::(${compareTarget}))`;
146
+ }
147
+
148
+ function parseJjBookmarkName(target: string): string | null {
149
+ if (!target || target.startsWith("@") || /[()\s]/.test(target)) return null;
150
+ return target;
151
+ }
152
+
153
+ function quoteJjString(value: string): string {
154
+ return JSON.stringify(value);
155
+ }
156
+
157
+ export async function getCurrentBranch(
158
+ runtime: ReviewGitRuntime,
159
+ cwd?: string,
160
+ ): Promise<string> {
161
+ const result = await runtime.runGit(
162
+ ["rev-parse", "--abbrev-ref", "HEAD"],
163
+ { cwd },
164
+ );
165
+ return result.exitCode === 0 ? result.stdout.trim() || "HEAD" : "HEAD";
166
+ }
167
+
168
+ export async function getDefaultBranch(
169
+ runtime: ReviewGitRuntime,
170
+ cwd?: string,
171
+ ): Promise<string> {
172
+ // Prefer the remote tracking ref (e.g. `origin/main`) so diffs run against
173
+ // the upstream tip, not a potentially stale local copy. Only fall back to
174
+ // a local ref when there's no remote configured at all.
175
+ const remoteHead = await runtime.runGit(
176
+ ["symbolic-ref", "refs/remotes/origin/HEAD"],
177
+ { cwd },
178
+ );
179
+ if (remoteHead.exitCode === 0) {
180
+ const ref = remoteHead.stdout.trim();
181
+ if (ref) {
182
+ // `symbolic-ref` only tells us what origin/HEAD *points at* — it does
183
+ // not guarantee that the target ref was actually fetched. In narrow
184
+ // or partial clones the pointer can be set while the target is
185
+ // missing, in which case a later `git diff origin/main..HEAD` would
186
+ // error. Verify the target exists before trusting it.
187
+ const verify = await runtime.runGit(
188
+ ["show-ref", "--verify", "--quiet", ref],
189
+ { cwd },
190
+ );
191
+ if (verify.exitCode === 0) return ref.replace("refs/remotes/", "");
192
+ }
193
+ }
194
+
195
+ const mainBranch = await runtime.runGit(
196
+ ["show-ref", "--verify", "refs/heads/main"],
197
+ { cwd },
198
+ );
199
+ if (mainBranch.exitCode === 0) return "main";
200
+
201
+ return "master";
202
+ }
203
+
204
+ /**
205
+ * Query the remote for its default branch via `ls-remote --symref`. Returns
206
+ * `origin/<name>` if the remote answers and the tracking ref exists locally,
207
+ * otherwise `null`. Designed to run in the background at server startup — the
208
+ * caller fires it with `.then()` and uses the result if/when it arrives.
209
+ *
210
+ * Timeout-guarded: if the network is slow or absent, the promise resolves
211
+ * (with `null`) once the timeout fires. Never throws.
212
+ */
213
+ export async function detectRemoteDefaultBranch(
214
+ runtime: ReviewGitRuntime,
215
+ cwd?: string,
216
+ ): Promise<string | null> {
217
+ try {
218
+ const lsRemote = await runtime.runGit(
219
+ ["ls-remote", "--symref", "origin", "HEAD"],
220
+ { cwd, timeoutMs: 5000 },
221
+ );
222
+ if (lsRemote.exitCode !== 0) return null;
223
+ const match = lsRemote.stdout.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m);
224
+ if (!match) return null;
225
+ const remoteBranch = `origin/${match[1]}`;
226
+ const refExists = await runtime.runGit(
227
+ ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteBranch}`],
228
+ { cwd },
229
+ );
230
+ return refExists.exitCode === 0 ? remoteBranch : null;
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ const RECENT_COMMIT_LIMIT_DEFAULT = 20;
237
+ // US (\x1F) separator avoids collisions with commit subjects, author names, and
238
+ // dates while staying compatible with `git log --pretty=format`.
239
+ const COMMIT_FIELD_SEP = "\x1f";
240
+
241
+ /**
242
+ * Walk HEAD's ancestry and return the most-recent commits for the
243
+ * commit-baseline picker. Single `git log` call — fast (~ms).
244
+ */
245
+ export async function listRecentCommits(
246
+ runtime: ReviewGitRuntime,
247
+ cwd?: string,
248
+ limit: number = RECENT_COMMIT_LIMIT_DEFAULT,
249
+ ): Promise<RecentCommit[]> {
250
+ const fmt = ["%H", "%h", "%s", "%cr", "%an"].join(COMMIT_FIELD_SEP);
251
+ const result = await runtime.runGit(
252
+ ["log", `--max-count=${limit}`, `--pretty=format:${fmt}`, "HEAD"],
253
+ { cwd },
254
+ );
255
+ if (result.exitCode !== 0) return [];
256
+
257
+ const commits: RecentCommit[] = [];
258
+ for (const line of result.stdout.split("\n")) {
259
+ if (!line) continue;
260
+ const parts = line.split(COMMIT_FIELD_SEP);
261
+ if (parts.length < 5) continue;
262
+ // If a subject contains a literal US byte the split over-divides. sha/
263
+ // shortSha are fixed-shape at the start and relativeDate/author at the
264
+ // end, so rejoin everything between back into the subject.
265
+ const sha = parts[0];
266
+ const shortSha = parts[1];
267
+ const author = parts[parts.length - 1];
268
+ const relativeDate = parts[parts.length - 2];
269
+ const subject = parts.slice(2, parts.length - 2).join(COMMIT_FIELD_SEP);
270
+ commits.push({ sha, shortSha, subject, relativeDate, author });
271
+ }
272
+ return commits;
273
+ }
274
+
275
+ export async function listBranches(
276
+ runtime: ReviewGitRuntime,
277
+ cwd?: string,
278
+ ): Promise<AvailableBranches> {
279
+ // Emit `<full-refname>\t<short-name>` so we can classify by ref prefix
280
+ // without guessing from the short form — local branches can contain `/`
281
+ // (e.g. `feature/foo`), so `name.includes("/")` would misclassify them.
282
+ const result = await runtime.runGit(
283
+ [
284
+ "for-each-ref",
285
+ "--format=%(refname)\t%(refname:short)",
286
+ "refs/heads",
287
+ "refs/remotes",
288
+ ],
289
+ { cwd },
290
+ );
291
+ if (result.exitCode !== 0) return { local: [], remote: [] };
292
+
293
+ const local: string[] = [];
294
+ const remote: string[] = [];
295
+
296
+ for (const line of result.stdout.split("\n")) {
297
+ const [fullRef, shortName] = line.split("\t");
298
+ if (!fullRef || !shortName) continue;
299
+ if (shortName.endsWith("/HEAD")) continue;
300
+ if (fullRef.startsWith("refs/heads/")) {
301
+ local.push(shortName);
302
+ } else if (fullRef.startsWith("refs/remotes/")) {
303
+ remote.push(shortName);
304
+ }
305
+ }
306
+
307
+ // Keep both local and remote refs — they can point to different commits
308
+ // (stale local tracking branches are common) and users need to be able to
309
+ // pick either explicitly. The picker groups them separately for clarity.
310
+ local.sort();
311
+ remote.sort();
312
+
313
+ return { local, remote };
314
+ }
315
+
316
+ /**
317
+ * Pick a safe base branch. Trusts the caller verbatim if they supplied one,
318
+ * otherwise falls back to the detected default. Shared by Bun (`review.ts`)
319
+ * and Pi (`serverReview.ts`) so both runtimes behave identically.
320
+ *
321
+ * Why trust the caller: the UI picker only ever sends refs from the known
322
+ * list, and external/programmatic callers may pass tags, SHAs, or refs under
323
+ * non-`origin` remotes that we must not silently rewrite (a tag `release` is
324
+ * not the same commit as a branch `origin/release`). Invalid refs surface as
325
+ * git errors on the next diff call, which is better than silently producing
326
+ * a patch against the wrong commit.
327
+ */
328
+ export function resolveBaseBranch(
329
+ requested: string | undefined,
330
+ detected: string,
331
+ ): string {
332
+ return requested || detected;
333
+ }
334
+
335
+ export async function getWorktrees(
336
+ runtime: ReviewGitRuntime,
337
+ cwd?: string,
338
+ ): Promise<WorktreeInfo[]> {
339
+ const result = await runtime.runGit(["worktree", "list", "--porcelain"], { cwd });
340
+ if (result.exitCode !== 0) return [];
341
+
342
+ const entries: WorktreeInfo[] = [];
343
+ let current: Partial<WorktreeInfo> = {};
344
+
345
+ for (const line of result.stdout.split("\n")) {
346
+ if (line.startsWith("worktree ")) {
347
+ if (current.path) {
348
+ entries.push({
349
+ path: current.path,
350
+ head: current.head || "",
351
+ branch: current.branch ?? null,
352
+ });
353
+ }
354
+ current = { path: line.slice("worktree ".length) };
355
+ } else if (line.startsWith("HEAD ")) {
356
+ current.head = line.slice("HEAD ".length);
357
+ } else if (line.startsWith("branch ")) {
358
+ current.branch = line
359
+ .slice("branch ".length)
360
+ .replace("refs/heads/", "");
361
+ } else if (line === "detached") {
362
+ current.branch = null;
363
+ }
364
+ }
365
+
366
+ if (current.path) {
367
+ entries.push({
368
+ path: current.path,
369
+ head: current.head || "",
370
+ branch: current.branch ?? null,
371
+ });
372
+ }
373
+
374
+ return entries;
375
+ }
376
+
377
+ export async function getGitContext(
378
+ runtime: ReviewGitRuntime,
379
+ cwd?: string,
380
+ ): Promise<GitContext> {
381
+ const [currentBranch, defaultBranch, availableBranches, recentCommits] = await Promise.all([
382
+ getCurrentBranch(runtime, cwd),
383
+ getDefaultBranch(runtime, cwd),
384
+ listBranches(runtime, cwd),
385
+ listRecentCommits(runtime, cwd),
386
+ ]);
387
+
388
+ const diffOptions: DiffOption[] = [
389
+ { id: "uncommitted", label: "Uncommitted changes" },
390
+ { id: "staged", label: "Staged changes" },
391
+ { id: "unstaged", label: "Unstaged changes" },
392
+ { id: "last-commit", label: "Last commit" },
393
+ ];
394
+
395
+ // Always offer Branch diff / PR Diff when a default branch exists. The
396
+ // older guard hid them when the reviewer was on the default branch (the
397
+ // `vs <default>` diff from the default branch itself is always empty), but
398
+ // the base picker now lets reviewers compare against any branch from any
399
+ // branch, so there's no meaningless-by-construction option. Also: preserving
400
+ // diff mode across worktree switches and Pi's `initialBase` can land the
401
+ // reviewer on the default branch with branch/merge-base already active — the
402
+ // old guard hid the active mode's option, trapping them. Unconditional
403
+ // emission keeps the active option reachable in every flow.
404
+ if (defaultBranch) {
405
+ diffOptions.push({ id: "merge-base", label: "Committed changes" });
406
+ }
407
+
408
+ diffOptions.push({ id: "all", label: "All files (HEAD)" });
409
+
410
+ const [worktrees, currentTreePathResult] = await Promise.all([
411
+ getWorktrees(runtime, cwd),
412
+ runtime.runGit(["rev-parse", "--show-toplevel"], { cwd }),
413
+ ]);
414
+
415
+ const currentTreePath =
416
+ currentTreePathResult.exitCode === 0
417
+ ? currentTreePathResult.stdout.trim()
418
+ : null;
419
+
420
+ return {
421
+ currentBranch,
422
+ defaultBranch,
423
+ diffOptions,
424
+ worktrees: worktrees.filter((wt) => wt.path !== currentTreePath),
425
+ availableBranches,
426
+ compareTarget: {
427
+ diffTypes: ["branch", "merge-base"],
428
+ fallback: "main",
429
+ picker: {
430
+ rowLabel: "compare against",
431
+ triggerLabel: "base",
432
+ triggerTitlePrefix: "Review base",
433
+ searchPlaceholder: "Search branches…",
434
+ emptyText: "No branches match.",
435
+ localGroupLabel: "Local",
436
+ remoteGroupLabel: "Remote",
437
+ },
438
+ },
439
+ cwd,
440
+ vcsType: "git",
441
+ recentCommits,
442
+ };
443
+ }
444
+
445
+ async function getUntrackedFileDiffs(
446
+ runtime: ReviewGitRuntime,
447
+ srcPrefix = "a/",
448
+ dstPrefix = "b/",
449
+ cwd?: string,
450
+ options?: GitDiffOptions,
451
+ ): Promise<string> {
452
+ // git ls-files scopes to the CWD subtree and returns CWD-relative paths,
453
+ // unlike git diff HEAD which always covers the full repo with root-relative
454
+ // paths. Resolve the repo root so untracked files from the entire repo are
455
+ // included and their paths match the tracked-diff output.
456
+ const toplevelResult = await runtime.runGit(
457
+ ["rev-parse", "--show-toplevel"],
458
+ { cwd },
459
+ );
460
+ const rootCwd =
461
+ toplevelResult.exitCode === 0 ? toplevelResult.stdout.trim() : cwd;
462
+
463
+ const lsResult = await runtime.runGit(
464
+ ["ls-files", "--others", "--exclude-standard"],
465
+ { cwd: rootCwd },
466
+ );
467
+ if (lsResult.exitCode !== 0) return "";
468
+
469
+ const files = lsResult.stdout
470
+ .trim()
471
+ .split("\n")
472
+ .filter((file) => file.length > 0);
473
+
474
+ if (files.length === 0) return "";
475
+
476
+ const diffs = await Promise.all(
477
+ files.map(async (file) => {
478
+ const diffResult = await runtime.runGit(
479
+ [
480
+ "diff",
481
+ "--no-ext-diff",
482
+ ...(options?.hideWhitespace ? ["-w"] : []),
483
+ "--no-index",
484
+ `--src-prefix=${srcPrefix}`,
485
+ `--dst-prefix=${dstPrefix}`,
486
+ "/dev/null",
487
+ file,
488
+ ],
489
+ { cwd: rootCwd },
490
+ );
491
+ return diffResult.stdout;
492
+ }),
493
+ );
494
+
495
+ return diffs.join("");
496
+ }
497
+
498
+ /**
499
+ * If `ref` looks like a full or long hex SHA, return its 7-char prefix for
500
+ * display. Branch names, tags, and `HEAD~N` pass through unchanged.
501
+ */
502
+ function displayRef(ref: string): string {
503
+ return /^[0-9a-f]{7,}$/i.test(ref) ? ref.slice(0, 7) : ref;
504
+ }
505
+
506
+ function assertGitSuccess(
507
+ result: GitCommandResult,
508
+ args: string[],
509
+ ): GitCommandResult {
510
+ if (result.exitCode === 0) return result;
511
+
512
+ const command = `git ${args.join(" ")}`;
513
+ const stderr = result.stderr.trim();
514
+ throw new Error(
515
+ stderr
516
+ ? `${command} failed: ${stderr}`
517
+ : `${command} failed with exit code ${result.exitCode}`,
518
+ );
519
+ }
520
+
521
+ const WORKTREE_SUB_TYPES = new Set([
522
+ "uncommitted",
523
+ "staged",
524
+ "unstaged",
525
+ "last-commit",
526
+ "branch",
527
+ "merge-base",
528
+ "all",
529
+ ]);
530
+
531
+ export function parseWorktreeDiffType(
532
+ diffType: string,
533
+ ): { path: string; subType: string } | null {
534
+ if (!diffType.startsWith("worktree:")) return null;
535
+
536
+ const rest = diffType.slice("worktree:".length);
537
+ const lastColon = rest.lastIndexOf(":");
538
+ if (lastColon !== -1) {
539
+ const maybeSub = rest.slice(lastColon + 1);
540
+ if (WORKTREE_SUB_TYPES.has(maybeSub)) {
541
+ return { path: rest.slice(0, lastColon), subType: maybeSub };
542
+ }
543
+ }
544
+
545
+ return { path: rest, subType: "uncommitted" };
546
+ }
547
+
548
+ export async function runGitDiff(
549
+ runtime: ReviewGitRuntime,
550
+ diffType: DiffType,
551
+ defaultBranch: string = "main",
552
+ externalCwd?: string,
553
+ options?: GitDiffOptions,
554
+ ): Promise<DiffResult> {
555
+ let patch = "";
556
+ let label = "";
557
+ let cwd: string | undefined = externalCwd;
558
+ let effectiveDiffType = diffType as string;
559
+
560
+ if (diffType.startsWith("worktree:")) {
561
+ const parsed = parseWorktreeDiffType(diffType);
562
+ if (!parsed) {
563
+ return {
564
+ patch: "",
565
+ label: "Worktree error",
566
+ error: "Could not parse worktree diff type",
567
+ };
568
+ }
569
+ cwd = parsed.path;
570
+ effectiveDiffType = parsed.subType;
571
+ }
572
+
573
+ const wFlag = options?.hideWhitespace ? ["-w"] : [];
574
+
575
+ try {
576
+ switch (effectiveDiffType) {
577
+ case "uncommitted": {
578
+ const trackedDiffArgs = [
579
+ "diff",
580
+ "--no-ext-diff",
581
+ ...wFlag,
582
+ "HEAD",
583
+ "--src-prefix=a/",
584
+ "--dst-prefix=b/",
585
+ ];
586
+ const hasHead =
587
+ (await runtime.runGit(["rev-parse", "--verify", "HEAD"], { cwd }))
588
+ .exitCode === 0;
589
+ const trackedPatch = hasHead
590
+ ? assertGitSuccess(
591
+ await runtime.runGit(trackedDiffArgs, { cwd }),
592
+ trackedDiffArgs,
593
+ ).stdout
594
+ : "";
595
+ const untrackedDiff = await getUntrackedFileDiffs(
596
+ runtime,
597
+ "a/",
598
+ "b/",
599
+ cwd,
600
+ options,
601
+ );
602
+ patch = trackedPatch + untrackedDiff;
603
+ label = "Uncommitted changes";
604
+ break;
605
+ }
606
+
607
+ case "staged": {
608
+ const stagedDiffArgs = [
609
+ "diff",
610
+ "--no-ext-diff",
611
+ ...wFlag,
612
+ "--staged",
613
+ "--src-prefix=a/",
614
+ "--dst-prefix=b/",
615
+ ];
616
+ const stagedDiff = assertGitSuccess(
617
+ await runtime.runGit(stagedDiffArgs, { cwd }),
618
+ stagedDiffArgs,
619
+ );
620
+ patch = stagedDiff.stdout;
621
+ label = "Staged changes";
622
+ break;
623
+ }
624
+
625
+ case "unstaged": {
626
+ const trackedDiffArgs = [
627
+ "diff",
628
+ "--no-ext-diff",
629
+ ...wFlag,
630
+ "--src-prefix=a/",
631
+ "--dst-prefix=b/",
632
+ ];
633
+ const trackedDiff = assertGitSuccess(
634
+ await runtime.runGit(trackedDiffArgs, { cwd }),
635
+ trackedDiffArgs,
636
+ );
637
+ const untrackedDiff = await getUntrackedFileDiffs(
638
+ runtime,
639
+ "a/",
640
+ "b/",
641
+ cwd,
642
+ options,
643
+ );
644
+ patch = trackedDiff.stdout + untrackedDiff;
645
+ label = "Unstaged changes";
646
+ break;
647
+ }
648
+
649
+ case "last-commit": {
650
+ const hasParent = await runtime.runGit(
651
+ ["rev-parse", "--verify", "HEAD~1"],
652
+ { cwd },
653
+ );
654
+ const args =
655
+ hasParent.exitCode === 0
656
+ ? ["diff", "--no-ext-diff", ...wFlag, "HEAD~1..HEAD", "--src-prefix=a/", "--dst-prefix=b/"]
657
+ : ["diff", "--no-ext-diff", ...wFlag, "--root", "HEAD", "--src-prefix=a/", "--dst-prefix=b/"];
658
+ const lastCommitDiff = assertGitSuccess(
659
+ await runtime.runGit(args, { cwd }),
660
+ args,
661
+ );
662
+ patch = lastCommitDiff.stdout;
663
+ label = "Last commit";
664
+ break;
665
+ }
666
+
667
+ case "branch": {
668
+ // `--end-of-options` hardens against a caller-supplied `defaultBranch`
669
+ // that starts with `-` being parsed as a git flag (e.g. `--output=...`
670
+ // would redirect diff output to an attacker-chosen path). Same pattern
671
+ // applied wherever user-controlled refs flow into a git argv.
672
+ const branchDiffArgs = [
673
+ "diff",
674
+ "--no-ext-diff",
675
+ ...wFlag,
676
+ "--src-prefix=a/",
677
+ "--dst-prefix=b/",
678
+ "--end-of-options",
679
+ `${defaultBranch}..HEAD`,
680
+ ];
681
+ const branchDiff = assertGitSuccess(
682
+ await runtime.runGit(branchDiffArgs, { cwd }),
683
+ branchDiffArgs,
684
+ );
685
+ patch = branchDiff.stdout;
686
+ label = `Changes vs ${displayRef(defaultBranch)}`;
687
+ break;
688
+ }
689
+
690
+ case "merge-base": {
691
+ const mergeBaseLookupArgs = ["merge-base", "--end-of-options", defaultBranch, "HEAD"];
692
+ const mergeBaseResult = assertGitSuccess(
693
+ await runtime.runGit(mergeBaseLookupArgs, { cwd }),
694
+ mergeBaseLookupArgs,
695
+ );
696
+ const mergeBase = mergeBaseResult.stdout.trim();
697
+ const mergeBaseDiffArgs = [
698
+ "diff",
699
+ "--no-ext-diff",
700
+ ...wFlag,
701
+ "--src-prefix=a/",
702
+ "--dst-prefix=b/",
703
+ "--end-of-options",
704
+ `${mergeBase}..HEAD`,
705
+ ];
706
+ const mergeBaseDiff = assertGitSuccess(
707
+ await runtime.runGit(mergeBaseDiffArgs, { cwd }),
708
+ mergeBaseDiffArgs,
709
+ );
710
+ patch = mergeBaseDiff.stdout;
711
+ label = `PR diff vs ${displayRef(defaultBranch)}`;
712
+ break;
713
+ }
714
+
715
+ case "all": {
716
+ // Diff from the empty tree to HEAD — shows every tracked file as an addition.
717
+ const emptyTreeResult = await runtime.runGit(["hash-object", "-t", "tree", "/dev/null"], { cwd });
718
+ const emptyTree = emptyTreeResult.exitCode === 0
719
+ ? emptyTreeResult.stdout.trim()
720
+ : "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
721
+ const allDiffArgs = [
722
+ "diff",
723
+ "--no-ext-diff",
724
+ ...wFlag,
725
+ "--src-prefix=a/",
726
+ "--dst-prefix=b/",
727
+ "--end-of-options",
728
+ `${emptyTree}..HEAD`,
729
+ ];
730
+ const allDiff = assertGitSuccess(
731
+ await runtime.runGit(allDiffArgs, { cwd }),
732
+ allDiffArgs,
733
+ );
734
+ patch = allDiff.stdout;
735
+ label = "All files";
736
+ break;
737
+ }
738
+
739
+ default:
740
+ return { patch: "", label: "Unknown diff type" };
741
+ }
742
+ } catch (error) {
743
+ const raw = error instanceof Error ? error.message : String(error);
744
+ // Git dumps its entire --help output on some failures; keep only the
745
+ // first meaningful line so the UI doesn't vomit a wall of text.
746
+ const firstLine = raw.split("\n").find((l) => l.trim().length > 0) ?? raw;
747
+ const message = firstLine.length > 200 ? firstLine.slice(0, 200) + "…" : firstLine;
748
+ return {
749
+ patch: "",
750
+ label: cwd ? "Worktree error" : `Error: ${diffType}`,
751
+ error: message,
752
+ };
753
+ }
754
+
755
+ if (cwd) {
756
+ const branch = await getCurrentBranch(runtime, cwd);
757
+ label =
758
+ branch && branch !== "HEAD"
759
+ ? `${branch}: ${label}`
760
+ : `${cwd.split("/").pop()}: ${label}`;
761
+ }
762
+
763
+ return { patch, label };
764
+ }
765
+
766
+ export async function runGitDiffWithContext(
767
+ runtime: ReviewGitRuntime,
768
+ diffType: DiffType,
769
+ gitContext: GitContext,
770
+ options?: GitDiffOptions,
771
+ ): Promise<DiffResult> {
772
+ return runGitDiff(runtime, diffType, gitContext.defaultBranch, gitContext.cwd, options);
773
+ }
774
+
775
+ export async function getFileContentsForDiff(
776
+ runtime: ReviewGitRuntime,
777
+ diffType: DiffType,
778
+ defaultBranch: string,
779
+ filePath: string,
780
+ oldPath?: string,
781
+ cwd?: string,
782
+ ): Promise<{ oldContent: string | null; newContent: string | null }> {
783
+ const oldFilePath = oldPath || filePath;
784
+
785
+ let effectiveDiffType = diffType as string;
786
+ if (diffType.startsWith("worktree:")) {
787
+ const parsed = parseWorktreeDiffType(diffType);
788
+ if (!parsed) return { oldContent: null, newContent: null };
789
+ cwd = parsed.path;
790
+ effectiveDiffType = parsed.subType;
791
+ }
792
+
793
+ async function gitShow(ref: string, path: string): Promise<string | null> {
794
+ // `--end-of-options` hardens against user-supplied refs starting with `-`.
795
+ const result = await runtime.runGit(["show", "--end-of-options", `${ref}:${path}`], { cwd });
796
+ return result.exitCode === 0 ? result.stdout : null;
797
+ }
798
+
799
+ async function readWorkingTree(path: string): Promise<string | null> {
800
+ const fullPath = cwd ? resolvePath(cwd, path) : path;
801
+ return runtime.readTextFile(fullPath);
802
+ }
803
+
804
+ switch (effectiveDiffType) {
805
+ case "uncommitted":
806
+ return {
807
+ oldContent: await gitShow("HEAD", oldFilePath),
808
+ newContent: await readWorkingTree(filePath),
809
+ };
810
+ case "staged":
811
+ return {
812
+ oldContent: await gitShow("HEAD", oldFilePath),
813
+ newContent: await gitShow(":0", filePath),
814
+ };
815
+ case "unstaged":
816
+ return {
817
+ oldContent: await gitShow(":0", oldFilePath),
818
+ newContent: await readWorkingTree(filePath),
819
+ };
820
+ case "last-commit":
821
+ return {
822
+ oldContent: await gitShow("HEAD~1", oldFilePath),
823
+ newContent: await gitShow("HEAD", filePath),
824
+ };
825
+ case "branch":
826
+ return {
827
+ oldContent: await gitShow(defaultBranch, oldFilePath),
828
+ newContent: await gitShow("HEAD", filePath),
829
+ };
830
+ case "merge-base": {
831
+ const mbResult = await runtime.runGit(["merge-base", "--end-of-options", defaultBranch, "HEAD"], { cwd });
832
+ const mb = mbResult.exitCode === 0 ? mbResult.stdout.trim() : defaultBranch;
833
+ return {
834
+ oldContent: await gitShow(mb, oldFilePath),
835
+ newContent: await gitShow("HEAD", filePath),
836
+ };
837
+ }
838
+ case "all":
839
+ return {
840
+ oldContent: null,
841
+ newContent: await gitShow("HEAD", filePath),
842
+ };
843
+ default:
844
+ return { oldContent: null, newContent: null };
845
+ }
846
+ }
847
+
848
+ export function validateFilePath(filePath: string): void {
849
+ if (filePath.includes("..") || filePath.startsWith("/")) {
850
+ throw new Error("Invalid file path");
851
+ }
852
+ }
853
+
854
+ async function ensureGitSuccess(
855
+ runtime: ReviewGitRuntime,
856
+ args: string[],
857
+ cwd?: string,
858
+ ): Promise<void> {
859
+ const result = await runtime.runGit(args, { cwd });
860
+ if (result.exitCode !== 0) {
861
+ throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
862
+ }
863
+ }
864
+
865
+ export async function gitAddFile(
866
+ runtime: ReviewGitRuntime,
867
+ filePath: string,
868
+ cwd?: string,
869
+ ): Promise<void> {
870
+ validateFilePath(filePath);
871
+ await ensureGitSuccess(runtime, ["add", "--", filePath], cwd);
872
+ }
873
+
874
+ export async function gitResetFile(
875
+ runtime: ReviewGitRuntime,
876
+ filePath: string,
877
+ cwd?: string,
878
+ ): Promise<void> {
879
+ validateFilePath(filePath);
880
+ await ensureGitSuccess(runtime, ["reset", "HEAD", "--", filePath], cwd);
881
+ }
882
+
883
+ export function parseP4DiffType(
884
+ diffType: string,
885
+ ): { changelist: string | "default" } | null {
886
+ if (diffType === "p4-default") return { changelist: "default" };
887
+ if (diffType.startsWith("p4-changelist:")) {
888
+ return { changelist: diffType.slice("p4-changelist:".length) };
889
+ }
890
+ return null;
891
+ }
892
+
893
+ export function isP4DiffType(diffType: string): boolean {
894
+ return parseP4DiffType(diffType) !== null;
895
+ }