@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
@@ -0,0 +1,358 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdtempSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { runGit } from "./diff-store"
6
+ import type { DiffStoreCommitOpsDeps } from "./diff-store-commit-ops.adapter"
7
+ import {
8
+ commitFiles,
9
+ discardFile,
10
+ generateCommitMessage,
11
+ ignoreFile,
12
+ } from "./diff-store-commit-ops.adapter"
13
+
14
+ async function makeRepo(): Promise<string> {
15
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-commit-ops-"))
16
+ await runGit(["init", "-b", "main"], dir)
17
+ await runGit(["config", "user.email", "test@kanna.local"], dir)
18
+ await runGit(["config", "user.name", "kanna-test"], dir)
19
+ writeFileSync(path.join(dir, "README.md"), "hello\n")
20
+ await runGit(["add", "README.md"], dir)
21
+ await runGit(["commit", "-m", "init"], dir)
22
+ return dir
23
+ }
24
+
25
+ function makeDeps(overrides: Partial<DiffStoreCommitOpsDeps> = {}): DiffStoreCommitOpsDeps {
26
+ return {
27
+ refreshSnapshot: async () => false,
28
+ ...overrides,
29
+ }
30
+ }
31
+
32
+ describe("commitFiles", () => {
33
+ test("throws when not a git repo", async () => {
34
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
35
+ await expect(
36
+ commitFiles(makeDeps(), {
37
+ projectId: "p1",
38
+ projectPath: dir,
39
+ paths: ["README.md"],
40
+ summary: "test commit",
41
+ mode: "commit_only",
42
+ })
43
+ ).rejects.toThrow("not in a git repository")
44
+ }, 15_000)
45
+
46
+ test("throws when summary is empty", async () => {
47
+ const repo = await makeRepo()
48
+ await expect(
49
+ commitFiles(makeDeps(), {
50
+ projectId: "p1",
51
+ projectPath: repo,
52
+ paths: ["README.md"],
53
+ summary: " ",
54
+ mode: "commit_only",
55
+ })
56
+ ).rejects.toThrow("Commit summary is required")
57
+ }, 15_000)
58
+
59
+ test("throws when no paths provided", async () => {
60
+ const repo = await makeRepo()
61
+ await expect(
62
+ commitFiles(makeDeps(), {
63
+ projectId: "p1",
64
+ projectPath: repo,
65
+ paths: [],
66
+ summary: "test commit",
67
+ mode: "commit_only",
68
+ })
69
+ ).rejects.toThrow("Select at least one file to commit")
70
+ }, 15_000)
71
+
72
+ test("throws when file is no longer changed", async () => {
73
+ const repo = await makeRepo()
74
+ await expect(
75
+ commitFiles(makeDeps(), {
76
+ projectId: "p1",
77
+ projectPath: repo,
78
+ paths: ["README.md"],
79
+ summary: "test commit",
80
+ mode: "commit_only",
81
+ })
82
+ ).rejects.toThrow("File is no longer changed")
83
+ }, 15_000)
84
+
85
+ test("commits an untracked file in commit_only mode", async () => {
86
+ const repo = await makeRepo()
87
+ writeFileSync(path.join(repo, "new-file.txt"), "content\n")
88
+
89
+ const result = await commitFiles(makeDeps(), {
90
+ projectId: "p1",
91
+ projectPath: repo,
92
+ paths: ["new-file.txt"],
93
+ summary: "add new file",
94
+ mode: "commit_only",
95
+ })
96
+
97
+ expect(result.ok).toBe(true)
98
+ if (result.ok) {
99
+ expect(result.mode).toBe("commit_only")
100
+ expect(result.pushed).toBe(false)
101
+ expect(result.branchName).toBe("main")
102
+ }
103
+ }, 30_000)
104
+
105
+ test("commits a modified tracked file in commit_only mode", async () => {
106
+ const repo = await makeRepo()
107
+ writeFileSync(path.join(repo, "README.md"), "modified content\n")
108
+
109
+ const result = await commitFiles(makeDeps(), {
110
+ projectId: "p1",
111
+ projectPath: repo,
112
+ paths: ["README.md"],
113
+ summary: "update readme",
114
+ mode: "commit_only",
115
+ })
116
+
117
+ expect(result.ok).toBe(true)
118
+ if (result.ok) {
119
+ expect(result.mode).toBe("commit_only")
120
+ expect(result.pushed).toBe(false)
121
+ }
122
+ }, 30_000)
123
+
124
+ test("calls refreshSnapshot on success", async () => {
125
+ const repo = await makeRepo()
126
+ writeFileSync(path.join(repo, "new-file.txt"), "content\n")
127
+
128
+ let called = false
129
+ const deps = makeDeps({
130
+ refreshSnapshot: async () => {
131
+ called = true
132
+ return true
133
+ },
134
+ })
135
+
136
+ await commitFiles(deps, {
137
+ projectId: "p1",
138
+ projectPath: repo,
139
+ paths: ["new-file.txt"],
140
+ summary: "add new file",
141
+ mode: "commit_only",
142
+ })
143
+
144
+ expect(called).toBe(true)
145
+ }, 30_000)
146
+
147
+ test("returns commit_only with pushed:false when no remote", async () => {
148
+ const repo = await makeRepo()
149
+ writeFileSync(path.join(repo, "new-file.txt"), "content\n")
150
+
151
+ const result = await commitFiles(makeDeps(), {
152
+ projectId: "p1",
153
+ projectPath: repo,
154
+ paths: ["new-file.txt"],
155
+ summary: "add new file",
156
+ mode: "commit_and_push",
157
+ })
158
+
159
+ expect(result.ok).toBe(true)
160
+ if (result.ok) {
161
+ expect(result.pushed).toBe(false)
162
+ }
163
+ }, 30_000)
164
+ })
165
+
166
+ describe("discardFile", () => {
167
+ test("throws when not a git repo", async () => {
168
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
169
+ await expect(
170
+ discardFile(makeDeps(), {
171
+ projectId: "p1",
172
+ projectPath: dir,
173
+ path: "some-file.txt",
174
+ })
175
+ ).rejects.toThrow("not in a git repository")
176
+ }, 15_000)
177
+
178
+ test("throws when file is no longer changed", async () => {
179
+ const repo = await makeRepo()
180
+ await expect(
181
+ discardFile(makeDeps(), {
182
+ projectId: "p1",
183
+ projectPath: repo,
184
+ path: "README.md",
185
+ })
186
+ ).rejects.toThrow("File is no longer changed")
187
+ }, 15_000)
188
+
189
+ test("discards an untracked file by deleting it", async () => {
190
+ const repo = await makeRepo()
191
+ const filePath = path.join(repo, "untracked.txt")
192
+ writeFileSync(filePath, "content\n")
193
+
194
+ const result = await discardFile(makeDeps(), {
195
+ projectId: "p1",
196
+ projectPath: repo,
197
+ path: "untracked.txt",
198
+ })
199
+
200
+ expect(result.snapshotChanged).toBe(false)
201
+ const exists = await Bun.file(filePath).exists()
202
+ expect(exists).toBe(false)
203
+ }, 30_000)
204
+
205
+ test("discards modifications to a tracked file via restore", async () => {
206
+ const repo = await makeRepo()
207
+ writeFileSync(path.join(repo, "README.md"), "modified content\n")
208
+
209
+ const result = await discardFile(makeDeps(), {
210
+ projectId: "p1",
211
+ projectPath: repo,
212
+ path: "README.md",
213
+ })
214
+
215
+ expect(result.snapshotChanged).toBe(false)
216
+
217
+ // Verify the file was restored
218
+ const content = await Bun.file(path.join(repo, "README.md")).text()
219
+ expect(content).toBe("hello\n")
220
+ }, 30_000)
221
+
222
+ test("calls refreshSnapshot after discard", async () => {
223
+ const repo = await makeRepo()
224
+ writeFileSync(path.join(repo, "untracked.txt"), "content\n")
225
+
226
+ let called = false
227
+ const deps = makeDeps({
228
+ refreshSnapshot: async () => {
229
+ called = true
230
+ return true
231
+ },
232
+ })
233
+
234
+ await discardFile(deps, {
235
+ projectId: "p1",
236
+ projectPath: repo,
237
+ path: "untracked.txt",
238
+ })
239
+
240
+ expect(called).toBe(true)
241
+ }, 30_000)
242
+ })
243
+
244
+ describe("ignoreFile", () => {
245
+ test("throws when not a git repo", async () => {
246
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
247
+ await expect(
248
+ ignoreFile(makeDeps(), {
249
+ projectId: "p1",
250
+ projectPath: dir,
251
+ path: "some-file.txt",
252
+ })
253
+ ).rejects.toThrow("not in a git repository")
254
+ }, 15_000)
255
+
256
+ test("throws when file is no longer changed", async () => {
257
+ const repo = await makeRepo()
258
+ await expect(
259
+ ignoreFile(makeDeps(), {
260
+ projectId: "p1",
261
+ projectPath: repo,
262
+ path: "README.md",
263
+ })
264
+ ).rejects.toThrow("File is no longer changed")
265
+ }, 15_000)
266
+
267
+ test("throws when file is tracked (not untracked)", async () => {
268
+ const repo = await makeRepo()
269
+ writeFileSync(path.join(repo, "README.md"), "modified content\n")
270
+ await expect(
271
+ ignoreFile(makeDeps(), {
272
+ projectId: "p1",
273
+ projectPath: repo,
274
+ path: "README.md",
275
+ })
276
+ ).rejects.toThrow("Only untracked files can be ignored")
277
+ }, 30_000)
278
+
279
+ test("adds untracked file to .gitignore", async () => {
280
+ const repo = await makeRepo()
281
+ writeFileSync(path.join(repo, "secret.txt"), "secret content\n")
282
+
283
+ const result = await ignoreFile(makeDeps(), {
284
+ projectId: "p1",
285
+ projectPath: repo,
286
+ path: "secret.txt",
287
+ })
288
+
289
+ expect(result.snapshotChanged).toBe(false)
290
+
291
+ const gitignoreContent = await Bun.file(path.join(repo, ".gitignore")).text()
292
+ expect(gitignoreContent).toContain("secret.txt")
293
+ }, 30_000)
294
+
295
+ test("calls refreshSnapshot after ignore", async () => {
296
+ const repo = await makeRepo()
297
+ writeFileSync(path.join(repo, "secret.txt"), "secret content\n")
298
+
299
+ let called = false
300
+ const deps = makeDeps({
301
+ refreshSnapshot: async () => {
302
+ called = true
303
+ return true
304
+ },
305
+ })
306
+
307
+ await ignoreFile(deps, {
308
+ projectId: "p1",
309
+ projectPath: repo,
310
+ path: "secret.txt",
311
+ })
312
+
313
+ expect(called).toBe(true)
314
+ }, 30_000)
315
+ })
316
+
317
+ describe("generateCommitMessage", () => {
318
+ test("throws when not a git repo", async () => {
319
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
320
+ await expect(
321
+ generateCommitMessage(makeDeps(), {
322
+ projectPath: dir,
323
+ paths: ["README.md"],
324
+ })
325
+ ).rejects.toThrow("not in a git repository")
326
+ }, 15_000)
327
+
328
+ test("throws when no paths provided", async () => {
329
+ const repo = await makeRepo()
330
+ await expect(
331
+ generateCommitMessage(makeDeps(), {
332
+ projectPath: repo,
333
+ paths: [],
334
+ })
335
+ ).rejects.toThrow("Select at least one file")
336
+ }, 15_000)
337
+
338
+ test("throws when file is no longer changed", async () => {
339
+ const repo = await makeRepo()
340
+ await expect(
341
+ generateCommitMessage(makeDeps(), {
342
+ projectPath: repo,
343
+ paths: ["README.md"],
344
+ })
345
+ ).rejects.toThrow("File is no longer changed")
346
+ }, 15_000)
347
+
348
+ test("deduplicates paths before processing", async () => {
349
+ const repo = await makeRepo()
350
+ // Both paths resolve to the same file — the error should mention the unique path
351
+ await expect(
352
+ generateCommitMessage(makeDeps(), {
353
+ projectPath: repo,
354
+ paths: ["README.md", "README.md"],
355
+ })
356
+ ).rejects.toThrow("File is no longer changed")
357
+ }, 15_000)
358
+ })
@@ -0,0 +1,258 @@
1
+ import path from "node:path"
2
+ import {
3
+ formatGitFailure,
4
+ readTextFileOrNull,
5
+ rmPathRecursive,
6
+ runGit,
7
+ writeTextFile,
8
+ } from "./diff-store-io.adapter"
9
+ import {
10
+ getBranchName,
11
+ getOriginRemoteUrl,
12
+ hasUpstreamBranch,
13
+ resolveRepo,
14
+ } from "./diff-store-git-branch.adapter"
15
+ import { appendGitIgnoreEntry, normalizeRepoRelativePath } from "./diff-store-parse"
16
+ import {
17
+ createCommitFailure,
18
+ createPushFailure,
19
+ } from "./diff-store-errors"
20
+ import {
21
+ createPatch,
22
+ discardAddedPath,
23
+ discardRenamedPath,
24
+ findDirtyPath,
25
+ listDirtyPaths,
26
+ readBaseFile,
27
+ readWorktreeFile,
28
+ } from "./diff-store-file-ops.adapter"
29
+ import type {
30
+ DiffCommitMode,
31
+ DiffCommitResult,
32
+ } from "../shared/types"
33
+ import { generateCommitMessageDetailed } from "./generate-commit-message"
34
+
35
+ export interface DiffStoreCommitOpsDeps {
36
+ readonly refreshSnapshot: (projectId: string, projectPath: string) => Promise<boolean>
37
+ }
38
+
39
+ export async function generateCommitMessage(
40
+ _deps: DiffStoreCommitOpsDeps,
41
+ args: { projectPath: string; paths: string[] }
42
+ ) {
43
+ const normalizedPaths = [...new Set(args.paths.map(normalizeRepoRelativePath))]
44
+ if (normalizedPaths.length === 0) {
45
+ throw new Error("Select at least one file")
46
+ }
47
+
48
+ const repo = await resolveRepo(args.projectPath)
49
+ if (!repo) {
50
+ throw new Error("Project is not in a git repository")
51
+ }
52
+
53
+ const currentDirtyPaths = await listDirtyPaths(repo.repoRoot)
54
+ const selectedFiles = await Promise.all(normalizedPaths.map(async (selectedPath) => {
55
+ const entry = currentDirtyPaths.find((candidate) => candidate.path === selectedPath)
56
+ if (!entry) {
57
+ throw new Error(`File is no longer changed: ${selectedPath}`)
58
+ }
59
+
60
+ const beforePath = entry.previousPath ?? selectedPath
61
+ const beforeText = await readBaseFile(repo.repoRoot, repo.baseCommit, beforePath)
62
+ const afterText = await readWorktreeFile(repo.repoRoot, selectedPath)
63
+ const patch = await createPatch(beforePath, selectedPath, beforeText, afterText)
64
+
65
+ return {
66
+ path: selectedPath,
67
+ changeType: entry.changeType,
68
+ patch,
69
+ }
70
+ }))
71
+
72
+ const branchName = await getBranchName(repo.repoRoot)
73
+ return await generateCommitMessageDetailed({
74
+ cwd: repo.repoRoot,
75
+ branchName,
76
+ files: selectedFiles,
77
+ })
78
+ }
79
+
80
+ export async function commitFiles(
81
+ deps: DiffStoreCommitOpsDeps,
82
+ args: {
83
+ projectId: string
84
+ projectPath: string
85
+ paths: string[]
86
+ summary: string
87
+ description?: string
88
+ mode: DiffCommitMode
89
+ }
90
+ ): Promise<DiffCommitResult> {
91
+ const summary = args.summary.trim()
92
+ const description = args.description?.trim()
93
+ if (!summary) {
94
+ throw new Error("Commit summary is required")
95
+ }
96
+
97
+ const normalizedPaths = [...new Set(args.paths.map(normalizeRepoRelativePath))]
98
+ if (normalizedPaths.length === 0) {
99
+ throw new Error("Select at least one file to commit")
100
+ }
101
+
102
+ const repo = await resolveRepo(args.projectPath)
103
+ if (!repo) {
104
+ throw new Error("Project is not in a git repository")
105
+ }
106
+ const [hasUpstream, originRemoteUrl] = await Promise.all([
107
+ hasUpstreamBranch(repo.repoRoot),
108
+ getOriginRemoteUrl(repo.repoRoot),
109
+ ])
110
+ const hasOriginRemote = originRemoteUrl !== null
111
+
112
+ const currentDirtyEntries = await listDirtyPaths(repo.repoRoot)
113
+ const currentDirtyPathsByPath = new Map(currentDirtyEntries.map((entry) => [entry.path, entry]))
114
+ const missingPaths = normalizedPaths.filter((relativePath) => !currentDirtyPathsByPath.has(relativePath))
115
+ if (missingPaths.length > 0) {
116
+ throw new Error(`File is no longer changed: ${missingPaths[0]}`)
117
+ }
118
+
119
+ const trackedPaths = normalizedPaths.filter((relativePath) => !currentDirtyPathsByPath.get(relativePath)?.isUntracked)
120
+ if (trackedPaths.length > 0) {
121
+ const addTrackedResult = await runGit(["add", "-u", "--", ...trackedPaths], repo.repoRoot)
122
+ if (addTrackedResult.exitCode !== 0) {
123
+ return createCommitFailure(args.mode, formatGitFailure(addTrackedResult))
124
+ }
125
+ }
126
+
127
+ const untrackedPaths = normalizedPaths.filter((relativePath) => currentDirtyPathsByPath.get(relativePath)?.isUntracked)
128
+ if (untrackedPaths.length > 0) {
129
+ const addUntrackedResult = await runGit(["add", "--", ...untrackedPaths], repo.repoRoot)
130
+ if (addUntrackedResult.exitCode !== 0) {
131
+ return createCommitFailure(args.mode, formatGitFailure(addUntrackedResult))
132
+ }
133
+ }
134
+
135
+ const commitArgs = ["commit", "--only", "-m", summary]
136
+ if (description) {
137
+ commitArgs.push("-m", description)
138
+ }
139
+ commitArgs.push("--", ...normalizedPaths)
140
+
141
+ const commitResult = await runGit(commitArgs, repo.repoRoot)
142
+ if (commitResult.exitCode !== 0) {
143
+ return createCommitFailure(args.mode, formatGitFailure(commitResult))
144
+ }
145
+
146
+ const snapshotChanged = await deps.refreshSnapshot(args.projectId, args.projectPath)
147
+ const branchName = await getBranchName(repo.repoRoot)
148
+
149
+ if (args.mode === "commit_only") {
150
+ return {
151
+ ok: true,
152
+ mode: args.mode,
153
+ branchName,
154
+ pushed: false,
155
+ snapshotChanged,
156
+ } satisfies DiffCommitResult
157
+ }
158
+
159
+ if (!hasUpstream && !hasOriginRemote) {
160
+ return {
161
+ ok: true,
162
+ mode: args.mode,
163
+ branchName,
164
+ pushed: false,
165
+ snapshotChanged,
166
+ } satisfies DiffCommitResult
167
+ }
168
+
169
+ const pushResult = hasUpstream
170
+ ? await runGit(["push"], repo.repoRoot)
171
+ : await runGit(["push", "-u", "origin", "HEAD"], repo.repoRoot)
172
+ if (pushResult.exitCode !== 0) {
173
+ return createPushFailure(args.mode, formatGitFailure(pushResult), snapshotChanged)
174
+ }
175
+
176
+ const postPushSnapshotChanged = await deps.refreshSnapshot(args.projectId, args.projectPath)
177
+
178
+ return {
179
+ ok: true,
180
+ mode: args.mode,
181
+ branchName,
182
+ pushed: true,
183
+ snapshotChanged: snapshotChanged || postPushSnapshotChanged,
184
+ } satisfies DiffCommitResult
185
+ }
186
+
187
+ export async function discardFile(
188
+ deps: DiffStoreCommitOpsDeps,
189
+ args: { projectId: string; projectPath: string; path: string }
190
+ ) {
191
+ const relativePath = normalizeRepoRelativePath(args.path)
192
+ const repo = await resolveRepo(args.projectPath)
193
+ if (!repo) {
194
+ throw new Error("Project is not in a git repository")
195
+ }
196
+
197
+ const entry = await findDirtyPath(repo.repoRoot, relativePath)
198
+ if (!entry) {
199
+ throw new Error(`File is no longer changed: ${relativePath}`)
200
+ }
201
+
202
+ if (entry.isUntracked) {
203
+ await rmPathRecursive(path.join(repo.repoRoot, entry.path))
204
+ } else if (entry.changeType === "added") {
205
+ await discardAddedPath(repo.repoRoot, repo.baseCommit !== null, entry.path)
206
+ await rmPathRecursive(path.join(repo.repoRoot, entry.path))
207
+ } else if (entry.changeType === "renamed") {
208
+ if (!repo.baseCommit) {
209
+ throw new Error("Cannot discard a rename before the repository has an initial commit")
210
+ }
211
+ await discardRenamedPath(repo.repoRoot, entry)
212
+ } else {
213
+ if (!repo.baseCommit) {
214
+ throw new Error("Cannot discard tracked changes before the repository has an initial commit")
215
+ }
216
+ const restoreResult = await runGit(["restore", "--staged", "--worktree", "--source=HEAD", "--", entry.path], repo.repoRoot)
217
+ if (restoreResult.exitCode !== 0) {
218
+ throw new Error(formatGitFailure(restoreResult) || "Failed to discard file changes")
219
+ }
220
+ }
221
+
222
+ return {
223
+ snapshotChanged: await deps.refreshSnapshot(args.projectId, args.projectPath),
224
+ }
225
+ }
226
+
227
+ export async function ignoreFile(
228
+ deps: DiffStoreCommitOpsDeps,
229
+ args: { projectId: string; projectPath: string; path: string }
230
+ ) {
231
+ const ignoreEntry = normalizeRepoRelativePath(args.path)
232
+ const repo = await resolveRepo(args.projectPath)
233
+ if (!repo) {
234
+ throw new Error("Project is not in a git repository")
235
+ }
236
+
237
+ const dirtyPaths = await listDirtyPaths(repo.repoRoot)
238
+ const exactEntry = dirtyPaths.find((candidate) => candidate.path === ignoreEntry)
239
+ if (exactEntry && !exactEntry.isUntracked) {
240
+ throw new Error("Only untracked files can be ignored from the diff viewer")
241
+ }
242
+
243
+ const entry = dirtyPaths.find((candidate) => candidate.isUntracked && (candidate.path === ignoreEntry || candidate.path.startsWith(ignoreEntry)))
244
+ if (!entry) {
245
+ throw new Error(`File is no longer changed: ${ignoreEntry}`)
246
+ }
247
+
248
+ const gitignorePath = path.join(repo.repoRoot, ".gitignore")
249
+ const currentContents = await readTextFileOrNull(gitignorePath)
250
+ const nextContents = appendGitIgnoreEntry(currentContents, ignoreEntry)
251
+ if (nextContents !== currentContents) {
252
+ await writeTextFile(gitignorePath, nextContents)
253
+ }
254
+
255
+ return {
256
+ snapshotChanged: await deps.refreshSnapshot(args.projectId, args.projectPath),
257
+ }
258
+ }
@@ -0,0 +1,85 @@
1
+ import { summarizeGitFailure } from "./diff-store-io.adapter"
2
+ import type { ChatSyncResult, DiffCommitMode, DiffCommitResult } from "../shared/types"
3
+
4
+ export function createCommitFailure(mode: DiffCommitMode, detail: string): DiffCommitResult {
5
+ const normalized = detail.toLowerCase()
6
+ let title = "Commit failed"
7
+ let message = summarizeGitFailure(detail, "Git could not create the commit.")
8
+
9
+ if (normalized.includes("ignored by one of your .gitignore files")) {
10
+ title = "Ignored files cannot be staged"
11
+ message = "One or more selected paths are ignored by .gitignore. Unignore them or remove them from the commit selection."
12
+ }
13
+
14
+ return {
15
+ ok: false,
16
+ mode,
17
+ phase: "commit",
18
+ title,
19
+ message,
20
+ detail,
21
+ }
22
+ }
23
+
24
+ export function createPushFailure(mode: DiffCommitMode, detail: string, snapshotChanged: boolean): DiffCommitResult {
25
+ const normalized = detail.toLowerCase()
26
+ let title = "Push failed"
27
+ let message = summarizeGitFailure(detail, "Git could not push the commit.")
28
+
29
+ if (normalized.includes("non-fast-forward") || normalized.includes("fetch first")) {
30
+ title = "Branch is not up to date"
31
+ message = "Your branch is behind its remote. Pull or rebase, then try pushing again."
32
+ } else if (normalized.includes("does not appear to be a git repository")) {
33
+ title = "No origin remote configured"
34
+ message = "This repository does not have an origin remote configured."
35
+ } else if (normalized.includes("has no upstream branch") || normalized.includes("set-upstream")) {
36
+ title = "No upstream branch configured"
37
+ message = "This branch does not have an upstream remote branch configured yet."
38
+ } else if (normalized.includes("merge conflict") || normalized.includes("resolve conflicts")) {
39
+ title = "Merge conflicts need resolution"
40
+ message = "Git reported conflicts while preparing the push. Resolve them, then try again."
41
+ } else if (normalized.includes("permission denied") || normalized.includes("authentication failed") || normalized.includes("could not read from remote repository")) {
42
+ title = "Remote authentication failed"
43
+ message = "Git could not authenticate with the remote repository."
44
+ }
45
+
46
+ return {
47
+ ok: false,
48
+ mode,
49
+ phase: "push",
50
+ title,
51
+ message,
52
+ detail,
53
+ localCommitCreated: true,
54
+ snapshotChanged,
55
+ }
56
+ }
57
+
58
+ export function createSyncPushFailure(detail: string, snapshotChanged: boolean): ChatSyncResult {
59
+ const normalized = detail.toLowerCase()
60
+ let title = "Push failed"
61
+ let message = summarizeGitFailure(detail, "Git could not push this branch.")
62
+
63
+ if (normalized.includes("non-fast-forward") || normalized.includes("fetch first")) {
64
+ title = "Branch is not up to date"
65
+ message = "Your branch is behind its remote. Pull or rebase, then try pushing again."
66
+ } else if (normalized.includes("has no upstream branch") || normalized.includes("set-upstream")) {
67
+ title = "No upstream branch configured"
68
+ message = "This branch does not have an upstream remote branch configured yet."
69
+ } else if (normalized.includes("merge conflict") || normalized.includes("resolve conflicts")) {
70
+ title = "Merge conflicts need resolution"
71
+ message = "Git reported conflicts while preparing the push. Resolve them, then try again."
72
+ } else if (normalized.includes("permission denied") || normalized.includes("authentication failed") || normalized.includes("could not read from remote repository")) {
73
+ title = "Remote authentication failed"
74
+ message = "Git could not authenticate with the remote repository."
75
+ }
76
+
77
+ return {
78
+ ok: false,
79
+ action: "push",
80
+ title,
81
+ message,
82
+ detail,
83
+ snapshotChanged,
84
+ }
85
+ }