@cuongtran001/kanna 1.3.0 → 1.4.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 (200) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-BHj370W7.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-1G88q1PY.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-D0L3Co5v.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-CQtERl3H.js} +1 -1
  5. package/dist/client/assets/channel-BP9IMEvd.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-DxOwt09f.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-Wry66y9R.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-D6p-Hi4x.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-CRQZt23N.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-BzgTKOM9.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CKfD-91C.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-a71B27wc.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-vv9gJwx0.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK--W6Wt7FF.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2--W6Wt7FF.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-Cu-dCCaI.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-D8coOWLK.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-NIwZ65jE.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-d46aE_le.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-xdm2UxWc.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-hDbLwC5K.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-MHUGG7ki.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-Ro6n9X70.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-DOZqvRMu.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-Bn2NXYIy.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DK44bfbC.js} +1 -1
  27. package/dist/client/assets/{index-C0U2kwuB.js → index-CWJpHyQy.js} +115 -115
  28. package/dist/client/assets/{index-CLsOWXff.css → index-Cyzd4zZx.css} +1 -1
  29. package/dist/client/assets/{index-CbSY9HfS.js → index-DU56d4oC.js} +1 -1
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-C2tW_Dwj.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-C__4Z1wz.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-Bbn5EawR.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-RgS5IRWl.js} +1 -1
  34. package/dist/client/assets/{linear-DLR6miy3.js → linear-DfI0qBO_.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-BiN10VHc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-BQhibhh3.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-BEfLRn7a.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-ba641Y2x.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-BGtl-tVO.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-DyziaSFr.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BfYBAYga.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-BfUfmLYA.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CdGcgYEq.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-T6z7dEFl.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-BAwQbFFd.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-DF1cRKsP.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BRtAZ8za.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-Brb-q76h.js} +1 -1
  49. package/dist/client/index.html +2 -2
  50. package/package.json +1 -1
  51. package/src/server/agent-coordinator-types.ts +158 -0
  52. package/src/server/agent-coordinator.ts +919 -0
  53. package/src/server/agent-deps-builders.ts +425 -0
  54. package/src/server/agent.test.ts +22 -6
  55. package/src/server/agent.ts +35 -4987
  56. package/src/server/chat-op-log.test.ts +63 -0
  57. package/src/server/chat-op-log.ts +71 -0
  58. package/src/server/chat-ops-diff.test.ts +120 -0
  59. package/src/server/chat-ops-diff.ts +97 -0
  60. package/src/server/chat-ops-parity.test.ts +140 -0
  61. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  62. package/src/server/claude-autocontinue-commands.ts +256 -0
  63. package/src/server/claude-cancel-handler.test.ts +472 -0
  64. package/src/server/claude-cancel-handler.ts +241 -0
  65. package/src/server/claude-chat-management.test.ts +395 -0
  66. package/src/server/claude-chat-management.ts +251 -0
  67. package/src/server/claude-harness-stream.test.ts +273 -0
  68. package/src/server/claude-harness-stream.ts +158 -0
  69. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  70. package/src/server/claude-loop-orch-commands.ts +466 -0
  71. package/src/server/claude-message-normalizer.test.ts +213 -0
  72. package/src/server/claude-message-normalizer.ts +436 -0
  73. package/src/server/claude-prompt-helpers.test.ts +248 -0
  74. package/src/server/claude-prompt-helpers.ts +139 -0
  75. package/src/server/claude-sdk-queue.test.ts +169 -0
  76. package/src/server/claude-sdk-queue.ts +82 -0
  77. package/src/server/claude-send-command.test.ts +414 -0
  78. package/src/server/claude-send-command.ts +420 -0
  79. package/src/server/claude-session-config-helpers.test.ts +279 -0
  80. package/src/server/claude-session-config-helpers.ts +151 -0
  81. package/src/server/claude-session-config.test.ts +250 -0
  82. package/src/server/claude-session-config.ts +159 -0
  83. package/src/server/claude-session-error-handler.test.ts +498 -0
  84. package/src/server/claude-session-error-handler.ts +361 -0
  85. package/src/server/claude-session-lifecycle.test.ts +669 -0
  86. package/src/server/claude-session-lifecycle.ts +283 -0
  87. package/src/server/claude-session-rebuild.test.ts +224 -0
  88. package/src/server/claude-session-rebuild.ts +101 -0
  89. package/src/server/claude-session-runner.test.ts +523 -0
  90. package/src/server/claude-session-runner.ts +521 -0
  91. package/src/server/claude-session-spawner.test.ts +401 -0
  92. package/src/server/claude-session-spawner.ts +401 -0
  93. package/src/server/claude-session-start.test.ts +205 -0
  94. package/src/server/claude-session-start.ts +277 -0
  95. package/src/server/claude-session-state-queries.test.ts +344 -0
  96. package/src/server/claude-session-state-queries.ts +160 -0
  97. package/src/server/claude-session-state.ts +104 -0
  98. package/src/server/claude-slash-commands.test.ts +277 -0
  99. package/src/server/claude-slash-commands.ts +234 -0
  100. package/src/server/claude-spawn-helpers.test.ts +221 -0
  101. package/src/server/claude-spawn-helpers.ts +179 -0
  102. package/src/server/claude-steer-log.test.ts +243 -0
  103. package/src/server/claude-steer-log.ts +58 -0
  104. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  105. package/src/server/claude-subagent-tool-response.ts +165 -0
  106. package/src/server/claude-subagent-wiring.test.ts +263 -0
  107. package/src/server/claude-subagent-wiring.ts +324 -0
  108. package/src/server/claude-tool-respond.test.ts +321 -0
  109. package/src/server/claude-tool-respond.ts +142 -0
  110. package/src/server/claude-turn-runner.test.ts +310 -0
  111. package/src/server/claude-turn-runner.ts +228 -0
  112. package/src/server/claude-turn-starter.test.ts +266 -0
  113. package/src/server/claude-turn-starter.ts +566 -0
  114. package/src/server/claude-usage-math.test.ts +169 -0
  115. package/src/server/claude-usage-math.ts +132 -0
  116. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  117. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  118. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  119. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  120. package/src/server/diff-store-errors.ts +85 -0
  121. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  122. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  123. package/src/server/diff-store-io.adapter.ts +27 -0
  124. package/src/server/diff-store-parse.test.ts +212 -0
  125. package/src/server/diff-store-parse.ts +122 -0
  126. package/src/server/diff-store-state.ts +82 -0
  127. package/src/server/diff-store.ts +76 -1854
  128. package/src/server/event-store-apply.test.ts +280 -0
  129. package/src/server/event-store-apply.ts +132 -0
  130. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  131. package/src/server/event-store-chat-lifecycle.ts +441 -0
  132. package/src/server/event-store-entity-write.ts +356 -0
  133. package/src/server/event-store-helpers.test.ts +230 -0
  134. package/src/server/event-store-helpers.ts +235 -0
  135. package/src/server/event-store-init.ts +276 -0
  136. package/src/server/event-store-messages.adapter.test.ts +532 -0
  137. package/src/server/event-store-messages.adapter.ts +455 -0
  138. package/src/server/event-store-orch-subscription.ts +124 -0
  139. package/src/server/event-store-orch.test.ts +462 -0
  140. package/src/server/event-store-orch.ts +312 -0
  141. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  142. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  143. package/src/server/event-store-snapshot.test.ts +597 -0
  144. package/src/server/event-store-snapshot.ts +658 -0
  145. package/src/server/event-store-subagent.test.ts +408 -0
  146. package/src/server/event-store-subagent.ts +198 -0
  147. package/src/server/event-store-tool-requests.test.ts +210 -0
  148. package/src/server/event-store-tool-requests.ts +131 -0
  149. package/src/server/event-store-transcript-write.adapter.ts +291 -0
  150. package/src/server/event-store-write-ops.test.ts +554 -0
  151. package/src/server/event-store-write-ops.ts +529 -0
  152. package/src/server/event-store.test.ts +75 -0
  153. package/src/server/event-store.ts +257 -2285
  154. package/src/server/events.ts +1 -1
  155. package/src/server/harness-types.ts +15 -1
  156. package/src/server/oauth-errors.ts +19 -0
  157. package/src/server/storage/backend.ts +7 -0
  158. package/src/server/storage/fs-storage.adapter.ts +21 -1
  159. package/src/server/storage/in-memory-storage.ts +12 -0
  160. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  161. package/src/server/ws-router-agent-ctrl.ts +137 -0
  162. package/src/server/ws-router-broadcast.ts +560 -0
  163. package/src/server/ws-router-chat.test.ts +461 -0
  164. package/src/server/ws-router-chat.ts +279 -0
  165. package/src/server/ws-router-defaults.test.ts +283 -0
  166. package/src/server/ws-router-defaults.ts +434 -0
  167. package/src/server/ws-router-diff.test.ts +167 -0
  168. package/src/server/ws-router-diff.ts +250 -0
  169. package/src/server/ws-router-envelope.ts +360 -0
  170. package/src/server/ws-router-misc.test.ts +422 -0
  171. package/src/server/ws-router-misc.ts +265 -0
  172. package/src/server/ws-router-orch.test.ts +190 -0
  173. package/src/server/ws-router-orch.ts +93 -0
  174. package/src/server/ws-router-project.test.ts +403 -0
  175. package/src/server/ws-router-project.ts +256 -0
  176. package/src/server/ws-router-push.test.ts +193 -0
  177. package/src/server/ws-router-push.ts +120 -0
  178. package/src/server/ws-router-settings.test.ts +173 -0
  179. package/src/server/ws-router-settings.ts +416 -0
  180. package/src/server/ws-router-skills.test.ts +194 -0
  181. package/src/server/ws-router-skills.ts +204 -0
  182. package/src/server/ws-router-utils.ts +238 -0
  183. package/src/server/ws-router.test.ts +90 -0
  184. package/src/server/ws-router.ts +340 -2254
  185. package/src/shared/app-settings-types.ts +365 -0
  186. package/src/shared/chat-ops.test.ts +138 -0
  187. package/src/shared/chat-ops.ts +71 -0
  188. package/src/shared/core-types.ts +27 -0
  189. package/src/shared/git-diff-types.ts +165 -0
  190. package/src/shared/mcp-types.ts +105 -0
  191. package/src/shared/protocol.ts +3 -2
  192. package/src/shared/provider-model-types.ts +437 -0
  193. package/src/shared/subagent-types.ts +198 -0
  194. package/src/shared/tool-call-types.ts +314 -0
  195. package/src/shared/transcript-types.ts +270 -0
  196. package/src/shared/types.ts +31 -1711
  197. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  198. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  199. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  200. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
@@ -0,0 +1,686 @@
1
+ import path from "node:path"
2
+ import {
3
+ runCommand,
4
+ runGit,
5
+ statOrNull,
6
+ summarizeGitFailure,
7
+ } from "./diff-store-io.adapter"
8
+ import type {
9
+ ChatBranchHistoryEntry,
10
+ ChatBranchHistorySnapshot,
11
+ GithubRelease,
12
+ } from "../shared/types"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type SelectedBranch =
19
+ | { kind: "local"; name: string }
20
+ | { kind: "remote"; name: string; remoteRef: string }
21
+ | {
22
+ kind: "pull_request"
23
+ name: string
24
+ prNumber: number
25
+ headRefName: string
26
+ headRepoCloneUrl?: string
27
+ isCrossRepository?: boolean
28
+ remoteRef?: string
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Repo / branch queries
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export async function resolveRepo(
36
+ projectPath: string
37
+ ): Promise<{ repoRoot: string; baseCommit: string | null } | null> {
38
+ const topLevel = await runGit(["rev-parse", "--show-toplevel"], projectPath)
39
+ if (topLevel.exitCode !== 0) {
40
+ return null
41
+ }
42
+
43
+ const repoRoot = topLevel.stdout.trim()
44
+ const head = await runGit(["rev-parse", "--verify", "HEAD"], repoRoot)
45
+ return {
46
+ repoRoot,
47
+ baseCommit: head.exitCode === 0 ? head.stdout.trim() : null,
48
+ }
49
+ }
50
+
51
+ export async function getBranchName(repoRoot: string) {
52
+ const symbolicRef = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot)
53
+ if (symbolicRef.exitCode === 0) {
54
+ return symbolicRef.stdout.trim()
55
+ }
56
+
57
+ const revParse = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot)
58
+ if (revParse.exitCode === 0) {
59
+ return revParse.stdout.trim()
60
+ }
61
+
62
+ return undefined
63
+ }
64
+
65
+ export async function hasUpstreamBranch(repoRoot: string) {
66
+ const upstream = await runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], repoRoot)
67
+ return upstream.exitCode === 0 && upstream.stdout.trim().length > 0
68
+ }
69
+
70
+ export async function getLastFetchedAt(repoRoot: string) {
71
+ const gitDirResult = await runGit(["rev-parse", "--git-dir"], repoRoot)
72
+ if (gitDirResult.exitCode !== 0) {
73
+ return undefined
74
+ }
75
+
76
+ const gitDir = gitDirResult.stdout.trim()
77
+ const fetchHeadPath = path.resolve(repoRoot, gitDir, "FETCH_HEAD")
78
+ try {
79
+ const fetchHeadStat = (await statOrNull(fetchHeadPath))!
80
+ return fetchHeadStat.mtime.toISOString()
81
+ } catch {
82
+ return undefined
83
+ }
84
+ }
85
+
86
+ export async function getUpstreamStatusCounts(repoRoot: string) {
87
+ const result = await runGit(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], repoRoot)
88
+ if (result.exitCode !== 0) {
89
+ return { aheadCount: undefined, behindCount: undefined }
90
+ }
91
+
92
+ const [aheadRaw, behindRaw] = result.stdout.trim().split(/\s+/u)
93
+ const aheadCount = Number.parseInt(aheadRaw ?? "", 10)
94
+ const behindCount = Number.parseInt(behindRaw ?? "", 10)
95
+ return {
96
+ aheadCount: Number.isFinite(aheadCount) ? aheadCount : undefined,
97
+ behindCount: Number.isFinite(behindCount) ? behindCount : undefined,
98
+ }
99
+ }
100
+
101
+ export async function getOriginRemoteUrl(repoRoot: string) {
102
+ const result = await runGit(["remote", "get-url", "origin"], repoRoot)
103
+ if (result.exitCode !== 0) {
104
+ return null
105
+ }
106
+ const remoteUrl = result.stdout.trim()
107
+ return remoteUrl.length > 0 ? remoteUrl : null
108
+ }
109
+
110
+ export async function getGitHubRemoteSlugs(repoRoot: string) {
111
+ const remotesResult = await runGit(["remote"], repoRoot)
112
+ if (remotesResult.exitCode !== 0) {
113
+ return new Map<string, string>()
114
+ }
115
+
116
+ const remoteNames = remotesResult.stdout
117
+ .split(/\r?\n/u)
118
+ .map((line) => line.trim())
119
+ .filter(Boolean)
120
+
121
+ const remoteSlugEntries = await Promise.all(remoteNames.map(async (remoteName) => {
122
+ const remoteUrlResult = await runGit(["remote", "get-url", remoteName], repoRoot)
123
+ if (remoteUrlResult.exitCode !== 0) {
124
+ return null
125
+ }
126
+ const repoSlug = extractGitHubRepoSlug(remoteUrlResult.stdout.trim())
127
+ return repoSlug ? [remoteName, repoSlug.toLowerCase()] as const : null
128
+ }))
129
+
130
+ return new Map(remoteSlugEntries.filter((entry): entry is readonly [string, string] => Boolean(entry)))
131
+ }
132
+
133
+ export async function getLocalBranchNames(repoRoot: string) {
134
+ const result = await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"], repoRoot)
135
+ if (result.exitCode !== 0) {
136
+ throw new Error(result.stderr.trim() || "Failed to list local branches")
137
+ }
138
+ return result.stdout
139
+ .split(/\r?\n/u)
140
+ .map((line) => line.trim())
141
+ .filter(Boolean)
142
+ .sort((left, right) => left.localeCompare(right))
143
+ }
144
+
145
+ export async function getRemoteBranchNames(repoRoot: string) {
146
+ const result = await runGit(["for-each-ref", "--format=%(refname:short)", "refs/remotes"], repoRoot)
147
+ if (result.exitCode !== 0) {
148
+ throw new Error(result.stderr.trim() || "Failed to list remote branches")
149
+ }
150
+ return result.stdout
151
+ .split(/\r?\n/u)
152
+ .map((line) => line.trim())
153
+ .filter((line) => line.length > 0 && !line.endsWith("/HEAD"))
154
+ .sort((left, right) => left.localeCompare(right))
155
+ }
156
+
157
+ export async function getBranchUpdatedAtMap(repoRoot: string, refPrefix: "refs/heads" | "refs/remotes") {
158
+ const result = await runGit(
159
+ ["for-each-ref", "--format=%(refname:short)\t%(committerdate:iso-strict)", refPrefix],
160
+ repoRoot
161
+ )
162
+ if (result.exitCode !== 0) {
163
+ throw new Error(result.stderr.trim() || "Failed to read branch update times")
164
+ }
165
+
166
+ const entries = new Map<string, string>()
167
+ for (const line of result.stdout.split(/\r?\n/u)) {
168
+ const trimmed = line.trim()
169
+ if (!trimmed) continue
170
+ const [name, updatedAt] = trimmed.split("\t")
171
+ if (!name || !updatedAt || (refPrefix === "refs/remotes" && name.endsWith("/HEAD"))) {
172
+ continue
173
+ }
174
+ entries.set(name, updatedAt)
175
+ }
176
+ return entries
177
+ }
178
+
179
+ export async function resolveDefaultBranchName(repoRoot: string) {
180
+ const originHead = await runGit(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], repoRoot)
181
+ if (originHead.exitCode === 0) {
182
+ const ref = originHead.stdout.trim()
183
+ if (ref.startsWith("origin/")) {
184
+ return ref.slice("origin/".length)
185
+ }
186
+ }
187
+
188
+ const localBranches = await getLocalBranchNames(repoRoot)
189
+ if (localBranches.includes("main")) return "main"
190
+ if (localBranches.includes("master")) return "master"
191
+ return (await getBranchName(repoRoot)) ?? localBranches[0] ?? undefined
192
+ }
193
+
194
+ export async function getRecentBranchNames(repoRoot: string) {
195
+ const result = await runGit(["reflog", "--format=%gs", "--max-count=100", "HEAD"], repoRoot)
196
+ if (result.exitCode !== 0) {
197
+ return []
198
+ }
199
+
200
+ const recent: string[] = []
201
+ const seen = new Set<string>()
202
+ for (const line of result.stdout.split(/\r?\n/u)) {
203
+ const match = /checkout: moving from .* to (?<branch>.+)$/u.exec(line.trim())
204
+ const branch = match?.groups?.branch?.trim()
205
+ if (!branch || branch === "HEAD" || branch.startsWith("refs/")) {
206
+ continue
207
+ }
208
+ if (seen.has(branch)) continue
209
+ seen.add(branch)
210
+ recent.push(branch)
211
+ }
212
+ return recent
213
+ }
214
+
215
+ export async function resolveSelectedBranchRef(repoRoot: string, branch: SelectedBranch) {
216
+ if (branch.kind === "local") {
217
+ const localBranchNames = await getLocalBranchNames(repoRoot)
218
+ if (!localBranchNames.includes(branch.name)) {
219
+ throw new Error(`Local branch not found: ${branch.name}`)
220
+ }
221
+ return {
222
+ ref: branch.name,
223
+ displayName: branch.name,
224
+ branchName: branch.name,
225
+ }
226
+ }
227
+
228
+ if (branch.kind === "remote") {
229
+ const remoteRef = branch.remoteRef.trim()
230
+ const remoteBranchNames = await getRemoteBranchNames(repoRoot)
231
+ if (!remoteBranchNames.includes(remoteRef)) {
232
+ throw new Error(`Remote branch not found: ${remoteRef}`)
233
+ }
234
+ return {
235
+ ref: remoteRef,
236
+ displayName: remoteRef,
237
+ branchName: branch.name,
238
+ }
239
+ }
240
+
241
+ const localBranchNames = await getLocalBranchNames(repoRoot)
242
+ if (localBranchNames.includes(branch.name)) {
243
+ return {
244
+ ref: branch.name,
245
+ displayName: `PR #${branch.prNumber}`,
246
+ branchName: branch.name,
247
+ }
248
+ }
249
+
250
+ const remoteRef = branch.remoteRef?.trim()
251
+ if (remoteRef) {
252
+ const remoteBranchNames = await getRemoteBranchNames(repoRoot)
253
+ if (remoteBranchNames.includes(remoteRef)) {
254
+ return {
255
+ ref: remoteRef,
256
+ displayName: `PR #${branch.prNumber}`,
257
+ branchName: branch.headRefName || branch.name,
258
+ }
259
+ }
260
+ }
261
+
262
+ if (branch.isCrossRepository) {
263
+ throw new Error("This pull request branch is not available locally yet. Check it out first before merging.")
264
+ }
265
+
266
+ throw new Error(`Pull request branch not found: ${branch.headRefName || branch.name}`)
267
+ }
268
+
269
+ export async function getMergeCommitCount(repoRoot: string, sourceRef: string) {
270
+ const result = await runGit(["rev-list", "--count", `HEAD..${sourceRef}`], repoRoot)
271
+ if (result.exitCode !== 0) {
272
+ throw new Error(result.stderr.trim() || "Failed to calculate merge commit count")
273
+ }
274
+
275
+ const commitCount = Number.parseInt(result.stdout.trim(), 10)
276
+ return Number.isFinite(commitCount) ? commitCount : 0
277
+ }
278
+
279
+ export async function predictMergeConflicts(repoRoot: string, sourceRef: string) {
280
+ // Try the newer `git merge-tree --write-tree` form (requires Git 2.38+).
281
+ const newResult = await runGit(["merge-tree", "--write-tree", "--messages", "HEAD", sourceRef], repoRoot)
282
+
283
+ // Exit code 129 means the --write-tree flag is not supported (Git < 2.38).
284
+ // Fall back to the legacy three-argument form.
285
+ if (newResult.exitCode !== 129) {
286
+ const output = `${newResult.stdout}\n${newResult.stderr}`.trim()
287
+
288
+ if (newResult.exitCode === 0) {
289
+ return { hasConflicts: false }
290
+ }
291
+
292
+ const normalizedOutput = output.toLowerCase()
293
+ if (newResult.exitCode === 1 || normalizedOutput.includes("conflict")) {
294
+ return {
295
+ hasConflicts: true,
296
+ detail: output || "Git reported merge conflicts for this branch pair.",
297
+ }
298
+ }
299
+
300
+ throw new Error(output || "Failed to analyze merge conflicts")
301
+ }
302
+
303
+ // Legacy fallback: `git merge-tree <base> HEAD <source>` (Git < 2.38).
304
+ const baseResult = await runGit(["merge-base", "HEAD", sourceRef], repoRoot)
305
+ if (baseResult.exitCode !== 0) {
306
+ throw new Error(baseResult.stderr.trim() || "Failed to find merge base")
307
+ }
308
+ const baseTree = baseResult.stdout.trim()
309
+
310
+ const legacyResult = await runGit(["merge-tree", baseTree, "HEAD", sourceRef], repoRoot)
311
+ const legacyOutput = `${legacyResult.stdout}\n${legacyResult.stderr}`.trim()
312
+
313
+ if (legacyResult.exitCode !== 0) {
314
+ throw new Error(legacyOutput || "Failed to analyze merge conflicts")
315
+ }
316
+
317
+ // In the legacy form, conflict markers (<<<<<<) appear in the output when there are conflicts.
318
+ if (legacyOutput.includes("<<<<<<<") || legacyOutput.toLowerCase().includes("conflict")) {
319
+ return {
320
+ hasConflicts: true,
321
+ detail: legacyOutput || "Git reported merge conflicts for this branch pair.",
322
+ }
323
+ }
324
+
325
+ return { hasConflicts: false }
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // GitHub URL parsing (pure)
330
+ // ---------------------------------------------------------------------------
331
+
332
+ export function extractGitHubRepoSlug(remoteUrl: string | null | undefined) {
333
+ if (!remoteUrl) return null
334
+
335
+ const sshMatch = /^git@github\.com:(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
336
+ if (sshMatch?.groups?.owner && sshMatch.groups.repo) {
337
+ return `${sshMatch.groups.owner}/${sshMatch.groups.repo}`
338
+ }
339
+
340
+ const sshProtocolMatch = /^ssh:\/\/git@github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
341
+ if (sshProtocolMatch?.groups?.owner && sshProtocolMatch.groups.repo) {
342
+ return `${sshProtocolMatch.groups.owner}/${sshProtocolMatch.groups.repo}`
343
+ }
344
+
345
+ const httpsMatch = /^https?:\/\/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
346
+ if (httpsMatch?.groups?.owner && httpsMatch.groups.repo) {
347
+ return `${httpsMatch.groups.owner}/${httpsMatch.groups.repo}`
348
+ }
349
+
350
+ return null
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // GitHub API — pull requests
355
+ // ---------------------------------------------------------------------------
356
+
357
+ export interface GitHubPullRequestResponseItem {
358
+ number: number
359
+ title: string
360
+ head?: {
361
+ ref?: string
362
+ label?: string
363
+ repo?: {
364
+ clone_url?: string
365
+ full_name?: string
366
+ } | null
367
+ }
368
+ base?: {
369
+ ref?: string
370
+ }
371
+ }
372
+
373
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
374
+
375
+ type GitHubCliApiLike = (path: string) => Promise<GitHubPullRequestResponseItem[] | null>
376
+
377
+ interface FetchGitHubPullRequestsDeps {
378
+ fetchImpl?: FetchLike
379
+ ghApiImpl?: GitHubCliApiLike
380
+ }
381
+
382
+ async function fetchGitHubPullRequestsViaGh(path: string): Promise<GitHubPullRequestResponseItem[] | null> {
383
+ const result = await runCommand([
384
+ "gh",
385
+ "api",
386
+ "-H",
387
+ "Accept: application/vnd.github+json",
388
+ path,
389
+ ])
390
+ if (result.exitCode !== 0) {
391
+ return null
392
+ }
393
+
394
+ const json: GitHubPullRequestResponseItem[] = JSON.parse(result.stdout)
395
+ return Array.isArray(json) ? json : []
396
+ }
397
+
398
+ export async function fetchGitHubPullRequests(
399
+ repoSlug: string,
400
+ deps: FetchLike | FetchGitHubPullRequestsDeps = fetch
401
+ ): Promise<GitHubPullRequestResponseItem[]> {
402
+ const fetchImpl = typeof deps === "function" ? deps : (deps.fetchImpl ?? fetch)
403
+ const ghApiImpl = typeof deps === "function" ? fetchGitHubPullRequestsViaGh : (deps.ghApiImpl ?? fetchGitHubPullRequestsViaGh)
404
+ const ghPath = `repos/${repoSlug}/pulls?state=open&per_page=50`
405
+
406
+ try {
407
+ const ghPulls = await ghApiImpl(ghPath)
408
+ if (ghPulls) {
409
+ return ghPulls
410
+ }
411
+ } catch {
412
+ // Fall back to an unauthenticated HTTP request when `gh` is unavailable.
413
+ }
414
+
415
+ const response = await fetchImpl(`https://api.github.com/repos/${repoSlug}/pulls?state=open&per_page=50`, {
416
+ headers: {
417
+ Accept: "application/vnd.github+json",
418
+ },
419
+ })
420
+
421
+ if (!response.ok) {
422
+ throw new Error(`GitHub pull requests request failed with status ${response.status}`)
423
+ }
424
+
425
+ const json: GitHubPullRequestResponseItem[] = await response.json()
426
+ return Array.isArray(json) ? json : []
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // GitHub API — releases
431
+ // ---------------------------------------------------------------------------
432
+
433
+ type GitHubReleasesCliApiLike = (path: string) => Promise<GithubRelease[] | null>
434
+
435
+ interface FetchGitHubReleasesDeps {
436
+ fetchImpl?: FetchLike
437
+ ghApiImpl?: GitHubReleasesCliApiLike
438
+ }
439
+
440
+ async function fetchGitHubReleasesViaGh(path: string): Promise<GithubRelease[] | null> {
441
+ const result = await runCommand([
442
+ "gh",
443
+ "api",
444
+ "-H",
445
+ "Accept: application/vnd.github+json",
446
+ path,
447
+ ])
448
+ if (result.exitCode !== 0) {
449
+ return null
450
+ }
451
+
452
+ const json: GithubRelease[] = JSON.parse(result.stdout)
453
+ return Array.isArray(json) ? json : []
454
+ }
455
+
456
+ // Fetches published releases for the changelog panel. Prefers the authenticated
457
+ // `gh` CLI (5000 req/hr) and falls back to an unauthenticated HTTP request
458
+ // (60 req/hr per IP) so the browser never hits GitHub's low anonymous limit.
459
+ export async function fetchGitHubReleases(
460
+ repoSlug: string,
461
+ deps: FetchLike | FetchGitHubReleasesDeps = fetch
462
+ ): Promise<GithubRelease[]> {
463
+ const fetchImpl = typeof deps === "function" ? deps : (deps.fetchImpl ?? fetch)
464
+ const ghApiImpl = typeof deps === "function" ? fetchGitHubReleasesViaGh : (deps.ghApiImpl ?? fetchGitHubReleasesViaGh)
465
+ const ghPath = `repos/${repoSlug}/releases`
466
+
467
+ let releases: GithubRelease[] | null = null
468
+ try {
469
+ releases = await ghApiImpl(ghPath)
470
+ } catch {
471
+ // Fall back to an unauthenticated HTTP request when `gh` is unavailable.
472
+ }
473
+
474
+ if (!releases) {
475
+ const response = await fetchImpl(`https://api.github.com/${ghPath}`, {
476
+ headers: {
477
+ Accept: "application/vnd.github+json",
478
+ },
479
+ })
480
+
481
+ if (!response.ok) {
482
+ throw new Error(`GitHub releases request failed with status ${response.status}`)
483
+ }
484
+
485
+ releases = await response.json()
486
+ }
487
+
488
+ return Array.isArray(releases) ? releases.filter((release) => !release.draft) : []
489
+ }
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // Commit history
493
+ // ---------------------------------------------------------------------------
494
+
495
+ function buildGitHubCommitUrl(remoteUrl: string | null, sha: string) {
496
+ const slug = extractGitHubRepoSlug(remoteUrl)
497
+ return slug ? `https://github.com/${slug}/commit/${sha}` : undefined
498
+ }
499
+
500
+ async function getTagsByCommit(repoRoot: string, shas: string[]): Promise<Map<string, string[]>> {
501
+ const tagMap = new Map<string, string[]>()
502
+ if (shas.length === 0) return tagMap
503
+
504
+ for (const sha of shas) {
505
+ tagMap.set(sha, [])
506
+ }
507
+
508
+ const result = await runGit(
509
+ ["log", "--max-count", String(shas.length), "--decorate-refs=refs/tags", "--format=%H %D", shas[0]!],
510
+ repoRoot
511
+ )
512
+
513
+ if (result.exitCode !== 0) return tagMap
514
+
515
+ for (const line of result.stdout.split(/\r?\n/u)) {
516
+ const trimmed = line.trim()
517
+ if (!trimmed) continue
518
+ const spaceIndex = trimmed.indexOf(" ")
519
+ if (spaceIndex < 0) continue
520
+ const sha = trimmed.slice(0, spaceIndex)
521
+ const decorations = trimmed.slice(spaceIndex + 1)
522
+ if (!tagMap.has(sha) || !decorations) continue
523
+ const tags = decorations
524
+ .split(",")
525
+ .map((decoration) => decoration.trim())
526
+ .filter((decoration) => decoration.startsWith("tag: "))
527
+ .map((decoration) => decoration.slice(5))
528
+ .filter(Boolean)
529
+ .sort((left, right) => left.localeCompare(right))
530
+ tagMap.set(sha, tags)
531
+ }
532
+
533
+ return tagMap
534
+ }
535
+
536
+ export async function getBranchHistory(args: {
537
+ repoRoot: string
538
+ ref: string
539
+ limit: number
540
+ }): Promise<ChatBranchHistorySnapshot> {
541
+ const logResult = await runGit(
542
+ [
543
+ "log",
544
+ "--max-count",
545
+ String(args.limit),
546
+ "--pretty=format:%H%x1f%s%x1f%b%x1f%an%x1f%aI%x1e",
547
+ args.ref,
548
+ ],
549
+ args.repoRoot
550
+ )
551
+
552
+ if (logResult.exitCode !== 0) {
553
+ throw new Error(logResult.stderr.trim() || "Failed to read git log")
554
+ }
555
+
556
+ const remoteUrl = await getOriginRemoteUrl(args.repoRoot)
557
+ const parsedRecords: Array<{ sha: string; summary: string; description: string; authorName?: string; authoredAt: string }> = []
558
+
559
+ for (const record of logResult.stdout.split("")) {
560
+ const trimmed = record.trim()
561
+ if (!trimmed) continue
562
+ const [sha, summary, description, authorName, authoredAt] = trimmed.split("")
563
+ if (!sha || !summary || !authoredAt) continue
564
+ parsedRecords.push({
565
+ sha,
566
+ summary,
567
+ description: (description ?? "").trim(),
568
+ authorName: authorName?.trim() || undefined,
569
+ authoredAt,
570
+ })
571
+ }
572
+
573
+ const tagMap = await getTagsByCommit(args.repoRoot, parsedRecords.map((record) => record.sha))
574
+
575
+ const entries: ChatBranchHistoryEntry[] = parsedRecords.map((record) => ({
576
+ ...record,
577
+ tags: tagMap.get(record.sha) ?? [],
578
+ githubUrl: buildGitHubCommitUrl(remoteUrl, record.sha),
579
+ }))
580
+
581
+ return { entries }
582
+ }
583
+
584
+ // ---------------------------------------------------------------------------
585
+ // Branch action failure factories
586
+ // ---------------------------------------------------------------------------
587
+
588
+ export function createBranchActionFailure(title: string, detail: string, fallback: string) {
589
+ return {
590
+ ok: false,
591
+ title,
592
+ message: summarizeGitFailure(detail, fallback),
593
+ detail,
594
+ } as const
595
+ }
596
+
597
+ export function createMergeActionFailure(args: {
598
+ title: string
599
+ detail: string
600
+ fallback: string
601
+ snapshotChanged: boolean
602
+ }) {
603
+ return {
604
+ ok: false,
605
+ title: args.title,
606
+ message: summarizeGitFailure(args.detail, args.fallback),
607
+ detail: args.detail,
608
+ snapshotChanged: args.snapshotChanged,
609
+ } as const
610
+ }
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // GitHub auth / publish helpers
614
+ // ---------------------------------------------------------------------------
615
+
616
+ export function sanitizeRepoName(name: string) {
617
+ return name
618
+ .trim()
619
+ .toLowerCase()
620
+ .replace(/[\s_]+/gu, "-")
621
+ .replace(/[^a-z0-9.-]+/gu, "-")
622
+ .replace(/-+/gu, "-")
623
+ .replace(/^-|-$/gu, "")
624
+ }
625
+
626
+ export async function getGhAuthInfo() {
627
+ const versionResult = await runCommand(["gh", "--version"])
628
+ if (versionResult.exitCode !== 0) {
629
+ return {
630
+ ghInstalled: false,
631
+ authenticated: false,
632
+ activeAccountLogin: undefined,
633
+ }
634
+ }
635
+
636
+ const authStatusResult = await runCommand(["gh", "auth", "status", "--json", "hosts"])
637
+ if (authStatusResult.exitCode !== 0) {
638
+ return {
639
+ ghInstalled: true,
640
+ authenticated: false,
641
+ activeAccountLogin: undefined,
642
+ }
643
+ }
644
+
645
+ try {
646
+ const parsed: { hosts?: Record<string, Array<{ active?: boolean; login?: string; state?: string }>> } = JSON.parse(authStatusResult.stdout)
647
+ const accounts = parsed.hosts?.["github.com"] ?? []
648
+ const activeAccount = accounts.find((account) => account.active) ?? accounts[0]
649
+ return {
650
+ ghInstalled: true,
651
+ authenticated: activeAccount?.state === "success",
652
+ activeAccountLogin: activeAccount?.login,
653
+ }
654
+ } catch {
655
+ return {
656
+ ghInstalled: true,
657
+ authenticated: false,
658
+ activeAccountLogin: undefined,
659
+ }
660
+ }
661
+ }
662
+
663
+ export async function getGitHubOwners(): Promise<string[]> {
664
+ const userResult = await runCommand(["gh", "api", "user", "--jq", ".login"])
665
+ if (userResult.exitCode !== 0) {
666
+ return []
667
+ }
668
+
669
+ const owners = new Set<string>()
670
+ const userLogin = userResult.stdout.trim()
671
+ if (userLogin) {
672
+ owners.add(userLogin)
673
+ }
674
+
675
+ const orgsResult = await runCommand(["gh", "api", "user/orgs", "--paginate", "--jq", ".[].login"])
676
+ if (orgsResult.exitCode === 0) {
677
+ for (const line of orgsResult.stdout.split(/\r?\n/u)) {
678
+ const login = line.trim()
679
+ if (login) {
680
+ owners.add(login)
681
+ }
682
+ }
683
+ }
684
+
685
+ return [...owners]
686
+ }