@cuongtran001/kanna 1.2.0 → 1.3.1

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 (196) hide show
  1. package/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
  5. package/dist/client/assets/channel-BEYTrUAm.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
  27. package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
  28. package/dist/client/assets/index-CLsOWXff.css +32 -0
  29. package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
  34. package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.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.oauth-rotation.test.ts +101 -0
  55. package/src/server/agent.test.ts +24 -8
  56. package/src/server/agent.ts +35 -4963
  57. package/src/server/auto-continue/read-model.test.ts +2 -2
  58. package/src/server/auto-continue/read-model.ts +4 -1
  59. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  60. package/src/server/claude-autocontinue-commands.ts +256 -0
  61. package/src/server/claude-cancel-handler.test.ts +472 -0
  62. package/src/server/claude-cancel-handler.ts +241 -0
  63. package/src/server/claude-chat-management.test.ts +395 -0
  64. package/src/server/claude-chat-management.ts +251 -0
  65. package/src/server/claude-harness-stream.test.ts +273 -0
  66. package/src/server/claude-harness-stream.ts +158 -0
  67. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  68. package/src/server/claude-loop-orch-commands.ts +466 -0
  69. package/src/server/claude-message-normalizer.test.ts +213 -0
  70. package/src/server/claude-message-normalizer.ts +436 -0
  71. package/src/server/claude-prompt-helpers.test.ts +248 -0
  72. package/src/server/claude-prompt-helpers.ts +139 -0
  73. package/src/server/claude-sdk-queue.test.ts +169 -0
  74. package/src/server/claude-sdk-queue.ts +82 -0
  75. package/src/server/claude-send-command.test.ts +414 -0
  76. package/src/server/claude-send-command.ts +420 -0
  77. package/src/server/claude-session-config-helpers.test.ts +279 -0
  78. package/src/server/claude-session-config-helpers.ts +151 -0
  79. package/src/server/claude-session-config.test.ts +250 -0
  80. package/src/server/claude-session-config.ts +159 -0
  81. package/src/server/claude-session-error-handler.test.ts +498 -0
  82. package/src/server/claude-session-error-handler.ts +361 -0
  83. package/src/server/claude-session-lifecycle.test.ts +669 -0
  84. package/src/server/claude-session-lifecycle.ts +283 -0
  85. package/src/server/claude-session-rebuild.test.ts +224 -0
  86. package/src/server/claude-session-rebuild.ts +101 -0
  87. package/src/server/claude-session-runner.test.ts +523 -0
  88. package/src/server/claude-session-runner.ts +521 -0
  89. package/src/server/claude-session-spawner.test.ts +401 -0
  90. package/src/server/claude-session-spawner.ts +401 -0
  91. package/src/server/claude-session-start.test.ts +205 -0
  92. package/src/server/claude-session-start.ts +277 -0
  93. package/src/server/claude-session-state-queries.test.ts +344 -0
  94. package/src/server/claude-session-state-queries.ts +160 -0
  95. package/src/server/claude-session-state.ts +104 -0
  96. package/src/server/claude-slash-commands.test.ts +277 -0
  97. package/src/server/claude-slash-commands.ts +234 -0
  98. package/src/server/claude-spawn-helpers.test.ts +221 -0
  99. package/src/server/claude-spawn-helpers.ts +179 -0
  100. package/src/server/claude-steer-log.test.ts +243 -0
  101. package/src/server/claude-steer-log.ts +58 -0
  102. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  103. package/src/server/claude-subagent-tool-response.ts +165 -0
  104. package/src/server/claude-subagent-wiring.test.ts +263 -0
  105. package/src/server/claude-subagent-wiring.ts +324 -0
  106. package/src/server/claude-tool-respond.test.ts +321 -0
  107. package/src/server/claude-tool-respond.ts +142 -0
  108. package/src/server/claude-turn-runner.test.ts +310 -0
  109. package/src/server/claude-turn-runner.ts +228 -0
  110. package/src/server/claude-turn-starter.test.ts +266 -0
  111. package/src/server/claude-turn-starter.ts +566 -0
  112. package/src/server/claude-usage-math.test.ts +169 -0
  113. package/src/server/claude-usage-math.ts +132 -0
  114. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  115. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  116. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  117. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  118. package/src/server/diff-store-errors.ts +85 -0
  119. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  120. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  121. package/src/server/diff-store-io.adapter.ts +27 -0
  122. package/src/server/diff-store-parse.test.ts +212 -0
  123. package/src/server/diff-store-parse.ts +122 -0
  124. package/src/server/diff-store-state.ts +82 -0
  125. package/src/server/diff-store.ts +76 -1854
  126. package/src/server/event-store-apply.test.ts +280 -0
  127. package/src/server/event-store-apply.ts +132 -0
  128. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  129. package/src/server/event-store-chat-lifecycle.ts +441 -0
  130. package/src/server/event-store-entity-write.ts +356 -0
  131. package/src/server/event-store-helpers.test.ts +230 -0
  132. package/src/server/event-store-helpers.ts +235 -0
  133. package/src/server/event-store-init.ts +276 -0
  134. package/src/server/event-store-messages.adapter.test.ts +365 -0
  135. package/src/server/event-store-messages.adapter.ts +213 -0
  136. package/src/server/event-store-orch-subscription.ts +124 -0
  137. package/src/server/event-store-orch.test.ts +462 -0
  138. package/src/server/event-store-orch.ts +312 -0
  139. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  140. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  141. package/src/server/event-store-snapshot.test.ts +597 -0
  142. package/src/server/event-store-snapshot.ts +658 -0
  143. package/src/server/event-store-subagent.test.ts +408 -0
  144. package/src/server/event-store-subagent.ts +198 -0
  145. package/src/server/event-store-tool-requests.test.ts +210 -0
  146. package/src/server/event-store-tool-requests.ts +131 -0
  147. package/src/server/event-store-transcript-write.adapter.ts +285 -0
  148. package/src/server/event-store-write-ops.test.ts +554 -0
  149. package/src/server/event-store-write-ops.ts +529 -0
  150. package/src/server/event-store.test.ts +35 -0
  151. package/src/server/event-store.ts +251 -2284
  152. package/src/server/events.ts +3 -0
  153. package/src/server/harness-types.ts +15 -1
  154. package/src/server/oauth-errors.ts +19 -0
  155. package/src/server/read-models.ts +26 -1
  156. package/src/server/subagent-orchestrator.ts +3 -0
  157. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  158. package/src/server/ws-router-agent-ctrl.ts +137 -0
  159. package/src/server/ws-router-broadcast.ts +488 -0
  160. package/src/server/ws-router-chat.test.ts +461 -0
  161. package/src/server/ws-router-chat.ts +279 -0
  162. package/src/server/ws-router-defaults.test.ts +283 -0
  163. package/src/server/ws-router-defaults.ts +434 -0
  164. package/src/server/ws-router-diff.test.ts +167 -0
  165. package/src/server/ws-router-diff.ts +250 -0
  166. package/src/server/ws-router-envelope.ts +329 -0
  167. package/src/server/ws-router-misc.test.ts +422 -0
  168. package/src/server/ws-router-misc.ts +265 -0
  169. package/src/server/ws-router-orch.test.ts +190 -0
  170. package/src/server/ws-router-orch.ts +93 -0
  171. package/src/server/ws-router-project.test.ts +403 -0
  172. package/src/server/ws-router-project.ts +256 -0
  173. package/src/server/ws-router-push.test.ts +193 -0
  174. package/src/server/ws-router-push.ts +120 -0
  175. package/src/server/ws-router-settings.test.ts +173 -0
  176. package/src/server/ws-router-settings.ts +416 -0
  177. package/src/server/ws-router-skills.test.ts +194 -0
  178. package/src/server/ws-router-skills.ts +204 -0
  179. package/src/server/ws-router-utils.ts +224 -0
  180. package/src/server/ws-router.ts +339 -2254
  181. package/src/shared/app-settings-types.ts +365 -0
  182. package/src/shared/core-types.ts +27 -0
  183. package/src/shared/git-diff-types.ts +165 -0
  184. package/src/shared/loop-progress.test.ts +106 -0
  185. package/src/shared/loop-progress.ts +88 -0
  186. package/src/shared/mcp-types.ts +105 -0
  187. package/src/shared/provider-model-types.ts +437 -0
  188. package/src/shared/subagent-types.ts +198 -0
  189. package/src/shared/tool-call-types.ts +314 -0
  190. package/src/shared/transcript-types.ts +270 -0
  191. package/src/shared/types.ts +27 -1668
  192. package/dist/client/assets/channel-BMkhjiLI.js +0 -1
  193. package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
  194. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
  195. package/dist/client/assets/index-H6C-f2Vp.css +0 -32
  196. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
@@ -70,3 +70,30 @@ export async function spawnCommandCapture(args: string[]): Promise<SpawnResult>
70
70
  export function getBunEnv(): Record<string, string | undefined> {
71
71
  return Bun.env
72
72
  }
73
+
74
+ export const NON_INTERACTIVE_GIT_ENV = {
75
+ GIT_TERMINAL_PROMPT: "0",
76
+ GIT_ASKPASS: "echo",
77
+ SSH_ASKPASS: "echo",
78
+ GCM_INTERACTIVE: "Never",
79
+ } as const
80
+
81
+ export function runGit(args: string[], cwd: string) {
82
+ return spawnGitCapture(args, cwd, { ...getBunEnv(), ...NON_INTERACTIVE_GIT_ENV })
83
+ }
84
+
85
+ export function runCommand(args: string[]) {
86
+ return spawnCommandCapture(args)
87
+ }
88
+
89
+ export function formatGitFailure(result: SpawnResult) {
90
+ return [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n")
91
+ }
92
+
93
+ export function summarizeGitFailure(detail: string, fallback: string) {
94
+ return detail
95
+ .split(/\r?\n/u)
96
+ .map((line) => line.trim())
97
+ .find((line) => line.length > 0)
98
+ ?? fallback
99
+ }
@@ -0,0 +1,212 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ appendGitIgnoreEntry,
4
+ countTextLines,
5
+ getContentDigest,
6
+ normalizeRepoRelativePath,
7
+ parseNumstatValue,
8
+ parseStatusPaths,
9
+ } from "./diff-store-parse"
10
+
11
+ describe("parseStatusPaths", () => {
12
+ test("parses modified file", () => {
13
+ const result = parseStatusPaths(" M src/foo.ts\n")
14
+ expect(result).toHaveLength(1)
15
+ expect(result[0]).toMatchObject({ path: "src/foo.ts", changeType: "modified", isUntracked: false })
16
+ })
17
+
18
+ test("parses untracked file", () => {
19
+ const result = parseStatusPaths("?? new-file.ts\n")
20
+ expect(result).toHaveLength(1)
21
+ expect(result[0]).toMatchObject({ path: "new-file.ts", changeType: "added", isUntracked: true })
22
+ })
23
+
24
+ test("parses added file", () => {
25
+ const result = parseStatusPaths("A staged.ts\n")
26
+ expect(result).toHaveLength(1)
27
+ expect(result[0]).toMatchObject({ path: "staged.ts", changeType: "added", isUntracked: false })
28
+ })
29
+
30
+ test("parses deleted file", () => {
31
+ const result = parseStatusPaths(" D gone.ts\n")
32
+ expect(result).toHaveLength(1)
33
+ expect(result[0]).toMatchObject({ path: "gone.ts", changeType: "deleted", isUntracked: false })
34
+ })
35
+
36
+ test("parses renamed file", () => {
37
+ const result = parseStatusPaths("R old.ts -> new.ts\n")
38
+ expect(result).toHaveLength(1)
39
+ expect(result[0]).toMatchObject({
40
+ path: "new.ts",
41
+ previousPath: "old.ts",
42
+ changeType: "renamed",
43
+ isUntracked: false,
44
+ })
45
+ })
46
+
47
+ test("sorts results by path", () => {
48
+ const output = "?? b.ts\n?? a.ts\n?? c.ts\n"
49
+ const result = parseStatusPaths(output)
50
+ expect(result.map((e) => e.path)).toEqual(["a.ts", "b.ts", "c.ts"])
51
+ })
52
+
53
+ test("ignores lines shorter than 4 chars", () => {
54
+ const result = parseStatusPaths("AB\n M valid.ts\n")
55
+ expect(result).toHaveLength(1)
56
+ expect(result[0]?.path).toBe("valid.ts")
57
+ })
58
+
59
+ test("skips lines with empty value after status code", () => {
60
+ const result = parseStatusPaths(" M \n M real.ts\n")
61
+ expect(result).toHaveLength(1)
62
+ expect(result[0]?.path).toBe("real.ts")
63
+ })
64
+
65
+ test("returns empty array for empty input", () => {
66
+ expect(parseStatusPaths("")).toEqual([])
67
+ expect(parseStatusPaths("\n\n")).toEqual([])
68
+ })
69
+ })
70
+
71
+ describe("getContentDigest", () => {
72
+ test("returns a hex string", () => {
73
+ const digest = getContentDigest({
74
+ changeType: "modified",
75
+ beforePath: "a.ts",
76
+ afterPath: "a.ts",
77
+ beforeText: "old",
78
+ afterText: "new",
79
+ })
80
+ expect(digest).toMatch(/^[\da-f]{40}$/)
81
+ })
82
+
83
+ test("same inputs produce same digest", () => {
84
+ const args = {
85
+ changeType: "modified" as const,
86
+ beforePath: "a.ts",
87
+ afterPath: "a.ts",
88
+ beforeText: "old",
89
+ afterText: "new",
90
+ }
91
+ expect(getContentDigest(args)).toBe(getContentDigest(args))
92
+ })
93
+
94
+ test("different inputs produce different digests", () => {
95
+ const base = {
96
+ changeType: "modified" as const,
97
+ beforePath: "a.ts",
98
+ afterPath: "a.ts",
99
+ beforeText: "old",
100
+ afterText: "new",
101
+ }
102
+ const changed = { ...base, afterText: "different" }
103
+ expect(getContentDigest(base)).not.toBe(getContentDigest(changed))
104
+ })
105
+
106
+ test("handles null text", () => {
107
+ const digest = getContentDigest({
108
+ changeType: "added",
109
+ beforePath: "a.ts",
110
+ afterPath: "a.ts",
111
+ beforeText: null,
112
+ afterText: "content",
113
+ })
114
+ expect(typeof digest).toBe("string")
115
+ expect(digest).toHaveLength(40)
116
+ })
117
+ })
118
+
119
+ describe("parseNumstatValue", () => {
120
+ test("parses integer", () => {
121
+ expect(parseNumstatValue("42")).toBe(42)
122
+ expect(parseNumstatValue("0")).toBe(0)
123
+ })
124
+
125
+ test("returns 0 for binary marker", () => {
126
+ expect(parseNumstatValue("-")).toBe(0)
127
+ })
128
+
129
+ test("returns 0 for empty string", () => {
130
+ expect(parseNumstatValue("")).toBe(0)
131
+ expect(parseNumstatValue(" ")).toBe(0)
132
+ })
133
+
134
+ test("returns 0 for non-numeric string", () => {
135
+ expect(parseNumstatValue("abc")).toBe(0)
136
+ })
137
+ })
138
+
139
+ describe("countTextLines", () => {
140
+ test("counts lines", () => {
141
+ expect(countTextLines("a\nb\nc\n")).toBe(3)
142
+ expect(countTextLines("a\nb")).toBe(2)
143
+ })
144
+
145
+ test("returns 0 for null", () => {
146
+ expect(countTextLines(null)).toBe(0)
147
+ })
148
+
149
+ test("returns 0 for empty string", () => {
150
+ expect(countTextLines("")).toBe(0)
151
+ })
152
+
153
+ test("handles single line with trailing newline", () => {
154
+ expect(countTextLines("hello\n")).toBe(1)
155
+ })
156
+
157
+ test("handles single line without trailing newline", () => {
158
+ expect(countTextLines("hello")).toBe(1)
159
+ })
160
+ })
161
+
162
+ describe("normalizeRepoRelativePath", () => {
163
+ test("passes through simple paths", () => {
164
+ expect(normalizeRepoRelativePath("src/foo.ts")).toBe("src/foo.ts")
165
+ })
166
+
167
+ test("strips leading ./", () => {
168
+ expect(normalizeRepoRelativePath("./src/foo.ts")).toBe("src/foo.ts")
169
+ })
170
+
171
+ test("rejects absolute paths", () => {
172
+ expect(() => normalizeRepoRelativePath("/etc/passwd")).toThrow("Invalid diff path")
173
+ })
174
+
175
+ test("rejects parent traversal", () => {
176
+ expect(() => normalizeRepoRelativePath("../outside")).toThrow("Invalid diff path")
177
+ })
178
+
179
+ test("rejects embedded traversal", () => {
180
+ expect(() => normalizeRepoRelativePath("src/../../../etc/passwd")).toThrow("Invalid diff path")
181
+ })
182
+
183
+ test("normalizes backslashes on Windows-style paths", () => {
184
+ expect(normalizeRepoRelativePath("src\\foo.ts")).toBe("src/foo.ts")
185
+ })
186
+ })
187
+
188
+ describe("appendGitIgnoreEntry", () => {
189
+ test("adds entry to empty file", () => {
190
+ expect(appendGitIgnoreEntry(null, "dist/")).toBe("dist/\n")
191
+ expect(appendGitIgnoreEntry("", "dist/")).toBe("dist/\n")
192
+ })
193
+
194
+ test("appends entry to existing content", () => {
195
+ expect(appendGitIgnoreEntry("node_modules/\n", "dist/")).toBe("node_modules/\ndist/\n")
196
+ })
197
+
198
+ test("does not duplicate existing entry", () => {
199
+ const result = appendGitIgnoreEntry("dist/\n", "dist/")
200
+ expect(result).toBe("dist/\n")
201
+ })
202
+
203
+ test("adds newline to file missing trailing newline before appending", () => {
204
+ const result = appendGitIgnoreEntry("node_modules/", "dist/")
205
+ expect(result).toBe("node_modules/\ndist/\n")
206
+ })
207
+
208
+ test("does not duplicate entry that exists without trailing newline", () => {
209
+ const result = appendGitIgnoreEntry("dist/", "dist/")
210
+ expect(result).toBe("dist/\n")
211
+ })
212
+ })
@@ -0,0 +1,122 @@
1
+ import { createHash } from "node:crypto"
2
+ import path from "node:path"
3
+ import type { ChatDiffFile } from "../shared/types"
4
+
5
+ export interface DirtyPathEntry {
6
+ path: string
7
+ previousPath?: string
8
+ changeType: ChatDiffFile["changeType"]
9
+ isUntracked: boolean
10
+ }
11
+
12
+ export function parseStatusPaths(output: string): DirtyPathEntry[] {
13
+ const entries: DirtyPathEntry[] = []
14
+ for (const rawLine of output.split(/\r?\n/u)) {
15
+ const line = rawLine.trimEnd()
16
+ if (line.length < 4) continue
17
+ const statusCode = line.slice(0, 2)
18
+ const value = line.slice(3)
19
+ if (!value) continue
20
+ const isUntracked = statusCode === "??"
21
+ const isRename = statusCode.includes("R")
22
+ const isDelete = statusCode.includes("D")
23
+ const isAdd = statusCode.includes("A") || isUntracked
24
+ let changeType: ChatDiffFile["changeType"]
25
+ if (isRename) {
26
+ changeType = "renamed"
27
+ } else if (isDelete) {
28
+ changeType = "deleted"
29
+ } else if (isAdd) {
30
+ changeType = "added"
31
+ } else {
32
+ changeType = "modified"
33
+ }
34
+
35
+ if (isRename && value.includes(" -> ")) {
36
+ const [previousPath, nextPath] = value.split(" -> ")
37
+ if (nextPath) {
38
+ entries.push({
39
+ path: nextPath,
40
+ previousPath: previousPath || undefined,
41
+ changeType,
42
+ isUntracked,
43
+ })
44
+ }
45
+ continue
46
+ }
47
+
48
+ entries.push({
49
+ path: value,
50
+ changeType,
51
+ isUntracked,
52
+ })
53
+ }
54
+ return entries.sort((left, right) => left.path.localeCompare(right.path))
55
+ }
56
+
57
+ export function getContentDigest(args: {
58
+ changeType: ChatDiffFile["changeType"]
59
+ beforePath: string
60
+ afterPath: string
61
+ beforeText: string | null
62
+ afterText: string | null
63
+ }): string {
64
+ return createHash("sha1")
65
+ .update(args.changeType)
66
+ .update("\u0000")
67
+ .update(args.beforePath)
68
+ .update("\u0000")
69
+ .update(args.afterPath)
70
+ .update("\u0000")
71
+ .update(args.beforeText ?? "")
72
+ .update("\u0000")
73
+ .update(args.afterText ?? "")
74
+ .digest("hex")
75
+ }
76
+
77
+ export function parseNumstatValue(value: string): number {
78
+ if (value === "-" || value.trim() === "") return 0
79
+ const parsed = Number.parseInt(value, 10)
80
+ return Number.isFinite(parsed) ? parsed : 0
81
+ }
82
+
83
+ export function countTextLines(text: string | null): number {
84
+ if (!text) return 0
85
+ const lines = text.split(/\r?\n/u)
86
+ if (lines.at(-1) === "") {
87
+ lines.pop()
88
+ }
89
+ return lines.length
90
+ }
91
+
92
+ export function normalizeRepoRelativePath(inputPath: string): string {
93
+ const normalized = path.posix.normalize(inputPath.replaceAll("\\", "/")).replace(/^\.\/+/u, "")
94
+ if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../") || path.posix.isAbsolute(normalized)) {
95
+ throw new Error(`Invalid diff path: ${inputPath}`)
96
+ }
97
+ return normalized
98
+ }
99
+
100
+ export function appendGitIgnoreEntry(currentContents: string | null, entry: string): string {
101
+ const normalizedContents = currentContents ?? ""
102
+ const existingEntries = normalizedContents
103
+ .split(/\r?\n/u)
104
+ .map((line) => line.trim())
105
+ .filter(Boolean)
106
+
107
+ if (existingEntries.includes(entry)) {
108
+ return normalizedContents.length > 0 && !normalizedContents.endsWith("\n")
109
+ ? `${normalizedContents}\n`
110
+ : normalizedContents
111
+ }
112
+
113
+ let prefix: string
114
+ if (normalizedContents.length === 0) {
115
+ prefix = ""
116
+ } else if (normalizedContents.endsWith("\n")) {
117
+ prefix = normalizedContents
118
+ } else {
119
+ prefix = `${normalizedContents}\n`
120
+ }
121
+ return `${prefix}${entry}\n`
122
+ }
@@ -0,0 +1,82 @@
1
+ import type {
2
+ BranchMetadata,
3
+ ChatBranchHistorySnapshot,
4
+ ChatDiffFile,
5
+ ChatDiffSnapshot,
6
+ UpstreamStatus,
7
+ } from "../shared/types"
8
+
9
+ export interface StoredChatDiffState extends BranchMetadata, UpstreamStatus {
10
+ status: ChatDiffSnapshot["status"]
11
+ files: ChatDiffFile[]
12
+ branchHistory: ChatBranchHistorySnapshot
13
+ }
14
+
15
+ export function createEmptyState(): StoredChatDiffState {
16
+ return {
17
+ status: "unknown",
18
+ branchName: undefined,
19
+ defaultBranchName: undefined,
20
+ hasOriginRemote: undefined,
21
+ originRepoSlug: undefined,
22
+ hasUpstream: undefined,
23
+ aheadCount: undefined,
24
+ behindCount: undefined,
25
+ lastFetchedAt: undefined,
26
+ files: [],
27
+ branchHistory: { entries: [] },
28
+ }
29
+ }
30
+
31
+ export function branchMetadataEqual(left: BranchMetadata, right: BranchMetadata): boolean {
32
+ return left.branchName === right.branchName
33
+ && left.defaultBranchName === right.defaultBranchName
34
+ && left.hasOriginRemote === right.hasOriginRemote
35
+ && left.originRepoSlug === right.originRepoSlug
36
+ && left.hasUpstream === right.hasUpstream
37
+ }
38
+
39
+ export function upstreamStatusEqual(left: UpstreamStatus, right: UpstreamStatus): boolean {
40
+ return left.aheadCount === right.aheadCount
41
+ && left.behindCount === right.behindCount
42
+ && left.lastFetchedAt === right.lastFetchedAt
43
+ }
44
+
45
+ export function branchHistoryEqual(left: ChatBranchHistorySnapshot, right: ChatBranchHistorySnapshot): boolean {
46
+ if (left.entries.length !== right.entries.length) return false
47
+ return left.entries.every((entry, index) => {
48
+ const other = right.entries[index]
49
+ return Boolean(other)
50
+ && entry.sha === other.sha
51
+ && entry.summary === other.summary
52
+ && entry.description === other.description
53
+ && entry.authorName === other.authorName
54
+ && entry.authoredAt === other.authoredAt
55
+ && entry.githubUrl === other.githubUrl
56
+ && entry.tags.length === other.tags.length
57
+ && entry.tags.every((tag, tagIndex) => tag === other.tags[tagIndex])
58
+ })
59
+ }
60
+
61
+ export function snapshotsEqual(left: StoredChatDiffState | undefined, right: StoredChatDiffState): boolean {
62
+ if (!left) {
63
+ return right.status === "unknown" && right.files.length === 0
64
+ }
65
+ if (left.status !== right.status) return false
66
+ if (!branchMetadataEqual(left, right)) return false
67
+ if (!upstreamStatusEqual(left, right)) return false
68
+ if (left.files.length !== right.files.length) return false
69
+ if (!branchHistoryEqual(left.branchHistory, right.branchHistory)) return false
70
+ return left.files.every((file, index) => {
71
+ const other = right.files[index]
72
+ return Boolean(other)
73
+ && file.path === other.path
74
+ && file.changeType === other.changeType
75
+ && file.isUntracked === other.isUntracked
76
+ && file.additions === other.additions
77
+ && file.deletions === other.deletions
78
+ && file.patchDigest === other.patchDigest
79
+ && file.mimeType === other.mimeType
80
+ && file.size === other.size
81
+ })
82
+ }