kanna-code 0.23.1 → 0.25.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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>Kanna</title>
8
- <script type="module" crossorigin src="/assets/index-y8BVCflz.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DADxOnBF.css">
8
+ <script type="module" crossorigin src="/assets/index-Tg96DNSK.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Hy4LEj8j.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.23.1",
4
+ "version": "0.25.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
2
2
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
3
3
  import { tmpdir } from "node:os"
4
4
  import path from "node:path"
5
- import { appendGitIgnoreEntry, DiffStore } from "./diff-store"
5
+ import { appendGitIgnoreEntry, DiffStore, extractGitHubRepoSlug, fetchGitHubPullRequests } from "./diff-store"
6
6
 
7
7
  async function run(command: string[], cwd: string) {
8
8
  const process = Bun.spawn(command, {
@@ -31,6 +31,12 @@ async function createRepo() {
31
31
  return root
32
32
  }
33
33
 
34
+ async function createBareRemote() {
35
+ const root = await mkdtemp(path.join(tmpdir(), "kanna-diff-remote-"))
36
+ await run(["git", "init", "--bare"], root)
37
+ return root
38
+ }
39
+
34
40
  const tempDirs: string[] = []
35
41
 
36
42
  describe("DiffStore", () => {
@@ -48,15 +54,18 @@ describe("DiffStore", () => {
48
54
 
49
55
  const store = new DiffStore(repoRoot)
50
56
  await store.initialize()
51
- await store.refreshSnapshot("chat-1", repoRoot)
57
+ await store.refreshSnapshot("project-1", repoRoot)
52
58
 
53
- const snapshot = store.getSnapshot("chat-1")
59
+ const snapshot = store.getProjectSnapshot("project-1")
54
60
  expect(snapshot.status).toBe("ready")
55
61
  expect(snapshot.files).toHaveLength(1)
56
62
  expect(snapshot.files[0]?.path).toBe("app.txt")
57
63
  expect(snapshot.files[0]?.isUntracked).toBe(false)
58
- expect(snapshot.files[0]?.patch).toContain("-base")
59
- expect(snapshot.files[0]?.patch).toContain("+changed")
64
+ expect(snapshot.files[0]?.additions).toBe(1)
65
+ expect(snapshot.files[0]?.deletions).toBe(1)
66
+ await expect(store.readPatch({ projectPath: repoRoot, path: "app.txt" })).resolves.toMatchObject({
67
+ patch: expect.stringContaining("-base"),
68
+ })
60
69
  })
61
70
 
62
71
  test("returns no_repo outside a git repository", async () => {
@@ -65,12 +74,13 @@ describe("DiffStore", () => {
65
74
 
66
75
  const store = new DiffStore(root)
67
76
  await store.initialize()
68
- await store.refreshSnapshot("chat-1", root)
77
+ await store.refreshSnapshot("project-1", root)
69
78
 
70
- expect(store.getSnapshot("chat-1")).toEqual({
79
+ expect(store.getProjectSnapshot("project-1")).toEqual({
71
80
  status: "no_repo",
72
81
  branchName: undefined,
73
82
  files: [],
83
+ branchHistory: { entries: [] },
74
84
  })
75
85
  })
76
86
 
@@ -87,10 +97,10 @@ describe("DiffStore", () => {
87
97
 
88
98
  const store = new DiffStore(repoRoot)
89
99
  await store.initialize()
90
- await store.refreshSnapshot("chat-1", repoRoot)
100
+ await store.refreshSnapshot("project-1", repoRoot)
91
101
 
92
102
  await store.commitFiles({
93
- chatId: "chat-1",
103
+ projectId: "project-1",
94
104
  projectPath: repoRoot,
95
105
  paths: ["app.txt"],
96
106
  summary: "Update app",
@@ -98,7 +108,7 @@ describe("DiffStore", () => {
98
108
  mode: "commit_only",
99
109
  })
100
110
 
101
- const snapshot = store.getSnapshot("chat-1")
111
+ const snapshot = store.getProjectSnapshot("project-1")
102
112
  expect(snapshot.status).toBe("ready")
103
113
  expect(snapshot.files).toHaveLength(1)
104
114
  expect(snapshot.files[0]?.path).toBe("notes.txt")
@@ -107,6 +117,37 @@ describe("DiffStore", () => {
107
117
  expect(lastMessage).toBe("Update app\n\nOnly app changes")
108
118
  })
109
119
 
120
+ test("commit_and_push publishes an unpublished branch", async () => {
121
+ const repoRoot = await createRepo()
122
+ const remoteRoot = await createBareRemote()
123
+ tempDirs.push(repoRoot, remoteRoot)
124
+ await run(["git", "remote", "add", "origin", remoteRoot], repoRoot)
125
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
126
+ await run(["git", "add", "."], repoRoot)
127
+ await run(["git", "commit", "-m", "init"], repoRoot)
128
+ await run(["git", "switch", "-c", "feature/publish-me"], repoRoot)
129
+ await writeFile(path.join(repoRoot, "app.txt"), "changed\n", "utf8")
130
+
131
+ const store = new DiffStore(repoRoot)
132
+ await store.initialize()
133
+ await store.refreshSnapshot("project-1", repoRoot)
134
+
135
+ const result = await store.commitFiles({
136
+ projectId: "project-1",
137
+ projectPath: repoRoot,
138
+ paths: ["app.txt"],
139
+ summary: "Publish branch",
140
+ mode: "commit_and_push",
141
+ })
142
+
143
+ expect(result).toMatchObject({
144
+ ok: true,
145
+ mode: "commit_and_push",
146
+ pushed: true,
147
+ })
148
+ expect((await run(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], repoRoot)).trim()).toBe("origin/feature/publish-me")
149
+ })
150
+
110
151
  test("detects renamed files", async () => {
111
152
  const repoRoot = await createRepo()
112
153
  tempDirs.push(repoRoot)
@@ -117,9 +158,9 @@ describe("DiffStore", () => {
117
158
 
118
159
  const store = new DiffStore(repoRoot)
119
160
  await store.initialize()
120
- await store.refreshSnapshot("chat-1", repoRoot)
161
+ await store.refreshSnapshot("project-1", repoRoot)
121
162
 
122
- const snapshot = store.getSnapshot("chat-1")
163
+ const snapshot = store.getProjectSnapshot("project-1")
123
164
  expect(snapshot.status).toBe("ready")
124
165
  expect(snapshot.files).toHaveLength(1)
125
166
  expect(snapshot.files[0]?.path).toBe("after.txt")
@@ -137,9 +178,9 @@ describe("DiffStore", () => {
137
178
 
138
179
  const store = new DiffStore(repoRoot)
139
180
  await store.initialize()
140
- await store.refreshSnapshot("chat-1", repoRoot)
181
+ await store.refreshSnapshot("project-1", repoRoot)
141
182
 
142
- const snapshot = store.getSnapshot("chat-1")
183
+ const snapshot = store.getProjectSnapshot("project-1")
143
184
  expect(snapshot.files).toHaveLength(1)
144
185
  expect(snapshot.files[0]).toMatchObject({
145
186
  path: "scratch.log",
@@ -158,15 +199,15 @@ describe("DiffStore", () => {
158
199
 
159
200
  const store = new DiffStore(repoRoot)
160
201
  await store.initialize()
161
- await store.refreshSnapshot("chat-1", repoRoot)
202
+ await store.refreshSnapshot("project-1", repoRoot)
162
203
  await store.discardFile({
163
- chatId: "chat-1",
204
+ projectId: "project-1",
164
205
  projectPath: repoRoot,
165
206
  path: "app.txt",
166
207
  })
167
208
 
168
209
  expect(await readFile(path.join(repoRoot, "app.txt"), "utf8")).toBe("base\n")
169
- expect(store.getSnapshot("chat-1").files).toHaveLength(0)
210
+ expect(store.getProjectSnapshot("project-1").files).toHaveLength(0)
170
211
  })
171
212
 
172
213
  test("discardFile deletes an untracked file", async () => {
@@ -179,15 +220,15 @@ describe("DiffStore", () => {
179
220
 
180
221
  const store = new DiffStore(repoRoot)
181
222
  await store.initialize()
182
- await store.refreshSnapshot("chat-1", repoRoot)
223
+ await store.refreshSnapshot("project-1", repoRoot)
183
224
  await store.discardFile({
184
- chatId: "chat-1",
225
+ projectId: "project-1",
185
226
  projectPath: repoRoot,
186
227
  path: "scratch.log",
187
228
  })
188
229
 
189
230
  expect(await Bun.file(path.join(repoRoot, "scratch.log")).exists()).toBe(false)
190
- expect(store.getSnapshot("chat-1").files).toHaveLength(0)
231
+ expect(store.getProjectSnapshot("project-1").files).toHaveLength(0)
191
232
  })
192
233
 
193
234
  test("discardFile reverts a renamed file", async () => {
@@ -200,16 +241,16 @@ describe("DiffStore", () => {
200
241
 
201
242
  const store = new DiffStore(repoRoot)
202
243
  await store.initialize()
203
- await store.refreshSnapshot("chat-1", repoRoot)
244
+ await store.refreshSnapshot("project-1", repoRoot)
204
245
  await store.discardFile({
205
- chatId: "chat-1",
246
+ projectId: "project-1",
206
247
  projectPath: repoRoot,
207
248
  path: "after.txt",
208
249
  })
209
250
 
210
251
  expect(await Bun.file(path.join(repoRoot, "before.txt")).exists()).toBe(true)
211
252
  expect(await Bun.file(path.join(repoRoot, "after.txt")).exists()).toBe(false)
212
- expect(store.getSnapshot("chat-1").files).toHaveLength(0)
253
+ expect(store.getProjectSnapshot("project-1").files).toHaveLength(0)
213
254
  })
214
255
 
215
256
  test("ignoreFile appends a .gitignore entry once", async () => {
@@ -222,9 +263,9 @@ describe("DiffStore", () => {
222
263
 
223
264
  const store = new DiffStore(repoRoot)
224
265
  await store.initialize()
225
- await store.refreshSnapshot("chat-1", repoRoot)
266
+ await store.refreshSnapshot("project-1", repoRoot)
226
267
  await store.ignoreFile({
227
- chatId: "chat-1",
268
+ projectId: "project-1",
228
269
  projectPath: repoRoot,
229
270
  path: "scratch.log",
230
271
  })
@@ -237,6 +278,36 @@ describe("DiffStore", () => {
237
278
  expect(appendGitIgnoreEntry("scratch.log", "scratch.log")).toBe("scratch.log\n")
238
279
  })
239
280
 
281
+ test("extractGitHubRepoSlug supports common remote URL formats", () => {
282
+ expect(extractGitHubRepoSlug("git@github.com:acme/repo.git")).toBe("acme/repo")
283
+ expect(extractGitHubRepoSlug("ssh://git@github.com/acme/repo.git")).toBe("acme/repo")
284
+ expect(extractGitHubRepoSlug("https://github.com/acme/repo.git")).toBe("acme/repo")
285
+ expect(extractGitHubRepoSlug("https://gitlab.com/acme/repo.git")).toBeNull()
286
+ })
287
+
288
+ test("refreshSnapshot includes recent branch history with tags and github URLs", async () => {
289
+ const repoRoot = await createRepo()
290
+ tempDirs.push(repoRoot)
291
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
292
+ await run(["git", "add", "."], repoRoot)
293
+ await run(["git", "commit", "-m", "Initial commit"], repoRoot)
294
+ await run(["git", "tag", "v1.0.0"], repoRoot)
295
+ await run(["git", "remote", "add", "origin", "git@github.com:acme/repo.git"], repoRoot)
296
+
297
+ const store = new DiffStore(repoRoot)
298
+ await store.initialize()
299
+ await store.refreshSnapshot("project-1", repoRoot)
300
+
301
+ const snapshot = store.getProjectSnapshot("project-1")
302
+ expect(snapshot.branchHistory?.entries).toHaveLength(1)
303
+ expect(snapshot.branchHistory?.entries[0]).toMatchObject({
304
+ summary: "Initial commit",
305
+ authorName: "Kanna",
306
+ tags: ["v1.0.0"],
307
+ githubUrl: expect.stringContaining("https://github.com/acme/repo/commit/"),
308
+ })
309
+ })
310
+
240
311
  test("ignoreFile rejects tracked files", async () => {
241
312
  const repoRoot = await createRepo()
242
313
  tempDirs.push(repoRoot)
@@ -247,12 +318,296 @@ describe("DiffStore", () => {
247
318
 
248
319
  const store = new DiffStore(repoRoot)
249
320
  await store.initialize()
250
- await store.refreshSnapshot("chat-1", repoRoot)
321
+ await store.refreshSnapshot("project-1", repoRoot)
251
322
 
252
323
  await expect(store.ignoreFile({
253
- chatId: "chat-1",
324
+ projectId: "project-1",
254
325
  projectPath: repoRoot,
255
326
  path: "app.txt",
256
327
  })).rejects.toThrow("Only untracked files can be ignored from the diff viewer")
257
328
  })
329
+
330
+ test("fetchGitHubPullRequests prefers gh api when available", async () => {
331
+ let requestedPath = ""
332
+
333
+ const pulls = await fetchGitHubPullRequests("acme/repo", {
334
+ ghApiImpl: async (path) => {
335
+ requestedPath = path
336
+ return [{ number: 7, title: "Fix bug", head: { ref: "feature/fix" } }]
337
+ },
338
+ fetchImpl: async () => {
339
+ throw new Error("fetch should not be used when gh succeeds")
340
+ },
341
+ })
342
+
343
+ expect(requestedPath).toBe("repos/acme/repo/pulls?state=open&per_page=50")
344
+ expect(pulls).toHaveLength(1)
345
+ })
346
+
347
+ test("fetchGitHubPullRequests falls back to fetch and sends the GitHub accept header", async () => {
348
+ let requestedUrl = ""
349
+ let requestedAcceptHeader = ""
350
+
351
+ const pulls = await fetchGitHubPullRequests("acme/repo", {
352
+ ghApiImpl: async () => null,
353
+ fetchImpl: async (input, init) => {
354
+ requestedUrl = String(input)
355
+ requestedAcceptHeader = String(new Headers(init?.headers).get("Accept"))
356
+ return new Response(JSON.stringify([{ number: 7, title: "Fix bug", head: { ref: "feature/fix" } }]), {
357
+ status: 200,
358
+ headers: { "Content-Type": "application/json" },
359
+ })
360
+ },
361
+ })
362
+
363
+ expect(requestedUrl).toBe("https://api.github.com/repos/acme/repo/pulls?state=open&per_page=50")
364
+ expect(requestedAcceptHeader).toBe("application/vnd.github+json")
365
+ expect(pulls).toHaveLength(1)
366
+ })
367
+
368
+ test("listBranches includes default branch, local and remote branches, and recent branches", async () => {
369
+ const repoRoot = await createRepo()
370
+ tempDirs.push(repoRoot)
371
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
372
+ await run(["git", "add", "."], repoRoot)
373
+ await run(["git", "commit", "-m", "init"], repoRoot)
374
+ await run(["git", "switch", "-c", "feature/recent"], repoRoot)
375
+ await run(["git", "switch", "-c", "feature/other"], repoRoot)
376
+ await run(["git", "switch", "feature/recent"], repoRoot)
377
+ await run(["git", "switch", "main"], repoRoot).catch(async () => run(["git", "switch", "master"], repoRoot))
378
+ await run(["git", "update-ref", "refs/remotes/origin/main", "HEAD"], repoRoot).catch(() => {})
379
+ await run(["git", "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"], repoRoot).catch(() => {})
380
+ await run(["git", "update-ref", "refs/remotes/origin/feature/remote", "HEAD"], repoRoot)
381
+
382
+ const store = new DiffStore(repoRoot)
383
+ await store.initialize()
384
+
385
+ const result = await store.listBranches({ projectPath: repoRoot })
386
+ expect(result.defaultBranchName).toBe("main")
387
+ expect(result.local.some((entry) => entry.name === "feature/recent")).toBe(true)
388
+ expect(result.remote.some((entry) => entry.remoteRef === "origin/feature/remote")).toBe(true)
389
+ expect(result.recent.some((entry) => entry.name === "feature/recent")).toBe(true)
390
+ })
391
+
392
+ test("listBranches hides remote PR head refs from the remote section", async () => {
393
+ const repoRoot = await createRepo()
394
+ tempDirs.push(repoRoot)
395
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
396
+ await run(["git", "add", "."], repoRoot)
397
+ await run(["git", "commit", "-m", "init"], repoRoot)
398
+ await run(["git", "remote", "add", "origin", "git@github.com:acme/repo.git"], repoRoot)
399
+ await run(["git", "remote", "add", "github-desktop-jane", "git@github.com:jane/repo.git"], repoRoot)
400
+ await run(["git", "update-ref", "refs/remotes/origin/main", "HEAD"], repoRoot).catch(() => {})
401
+ await run(["git", "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"], repoRoot).catch(() => {})
402
+ await run(["git", "update-ref", "refs/remotes/github-desktop-jane/feature/pr-branch", "HEAD"], repoRoot)
403
+ await run(["git", "update-ref", "refs/remotes/origin/feature/pr-branch", "HEAD"], repoRoot)
404
+ await run(["git", "update-ref", "refs/remotes/origin/feature/non-pr", "HEAD"], repoRoot)
405
+
406
+ const originalFetch = globalThis.fetch
407
+ globalThis.fetch = Object.assign(
408
+ async () => new Response(JSON.stringify([
409
+ {
410
+ number: 42,
411
+ title: "PR branch",
412
+ head: {
413
+ ref: "feature/pr-branch",
414
+ label: "jane:feature/pr-branch",
415
+ repo: {
416
+ clone_url: "git@github.com:jane/repo.git",
417
+ full_name: "jane/repo",
418
+ },
419
+ },
420
+ base: {
421
+ ref: "main",
422
+ },
423
+ },
424
+ ]), {
425
+ status: 200,
426
+ headers: { "Content-Type": "application/json" },
427
+ }),
428
+ { preconnect: originalFetch.preconnect.bind(originalFetch) }
429
+ ) as typeof fetch
430
+
431
+ try {
432
+ const store = new DiffStore(repoRoot)
433
+ await store.initialize()
434
+
435
+ const result = await store.listBranches({ projectPath: repoRoot })
436
+ expect(result.pullRequests.some((entry) => entry.prNumber === 42)).toBe(true)
437
+ expect(result.remote.some((entry) => entry.remoteRef === "github-desktop-jane/feature/pr-branch")).toBe(false)
438
+ expect(result.remote.some((entry) => entry.remoteRef === "origin/feature/pr-branch")).toBe(false)
439
+ expect(result.remote.some((entry) => entry.remoteRef === "origin/feature/non-pr")).toBe(true)
440
+ } finally {
441
+ globalThis.fetch = originalFetch
442
+ }
443
+ })
444
+
445
+ test("checkoutBranch creates a local tracking branch from a remote branch", async () => {
446
+ const repoRoot = await createRepo()
447
+ tempDirs.push(repoRoot)
448
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
449
+ await run(["git", "add", "."], repoRoot)
450
+ await run(["git", "commit", "-m", "init"], repoRoot)
451
+ await run(["git", "remote", "add", "origin", "git@github.com:acme/repo.git"], repoRoot)
452
+ await run(["git", "update-ref", "refs/remotes/origin/feature/remote", "HEAD"], repoRoot)
453
+
454
+ const store = new DiffStore(repoRoot)
455
+ await store.initialize()
456
+ const result = await store.checkoutBranch({
457
+ projectId: "project-1",
458
+ projectPath: repoRoot,
459
+ branch: { kind: "remote", name: "feature/remote", remoteRef: "origin/feature/remote" },
460
+ })
461
+
462
+ expect(result.ok).toBe(true)
463
+ expect((await run(["git", "branch", "--show-current"], repoRoot)).trim()).toBe("feature/remote")
464
+ })
465
+
466
+ test("checkoutBranch cancels when changes exist and bringChanges is false", async () => {
467
+ const repoRoot = await createRepo()
468
+ tempDirs.push(repoRoot)
469
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
470
+ await run(["git", "add", "."], repoRoot)
471
+ await run(["git", "commit", "-m", "init"], repoRoot)
472
+ await run(["git", "switch", "-c", "feature/other"], repoRoot)
473
+ await run(["git", "switch", "main"], repoRoot).catch(async () => run(["git", "switch", "master"], repoRoot))
474
+ await writeFile(path.join(repoRoot, "app.txt"), "changed\n", "utf8")
475
+
476
+ const store = new DiffStore(repoRoot)
477
+ await store.initialize()
478
+ const result = await store.checkoutBranch({
479
+ projectId: "project-1",
480
+ projectPath: repoRoot,
481
+ branch: { kind: "local", name: "feature/other" },
482
+ bringChanges: false,
483
+ })
484
+
485
+ expect(result.ok).toBe(false)
486
+ if (!result.ok) {
487
+ expect(result.cancelled).toBe(true)
488
+ }
489
+ })
490
+
491
+ test("createBranch creates and checks out a branch from a chosen base", async () => {
492
+ const repoRoot = await createRepo()
493
+ tempDirs.push(repoRoot)
494
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
495
+ await run(["git", "add", "."], repoRoot)
496
+ await run(["git", "commit", "-m", "init"], repoRoot)
497
+ await run(["git", "switch", "-c", "feature/base"], repoRoot)
498
+
499
+ const store = new DiffStore(repoRoot)
500
+ await store.initialize()
501
+ const result = await store.createBranch({
502
+ projectId: "project-1",
503
+ projectPath: repoRoot,
504
+ name: "feature/new",
505
+ baseBranchName: "feature/base",
506
+ })
507
+
508
+ expect(result.ok).toBe(true)
509
+ expect((await run(["git", "branch", "--show-current"], repoRoot)).trim()).toBe("feature/new")
510
+ })
511
+
512
+ test("previewMergeBranch reports up-to-date and mergeable states", async () => {
513
+ const repoRoot = await createRepo()
514
+ tempDirs.push(repoRoot)
515
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
516
+ await run(["git", "add", "."], repoRoot)
517
+ await run(["git", "commit", "-m", "init"], repoRoot)
518
+ await run(["git", "switch", "-c", "feature/preview"], repoRoot)
519
+
520
+ const store = new DiffStore(repoRoot)
521
+ await store.initialize()
522
+
523
+ const upToDatePreview = await store.previewMergeBranch({
524
+ projectPath: repoRoot,
525
+ branch: { kind: "local", name: "main" },
526
+ })
527
+
528
+ expect(upToDatePreview.status).toBe("up_to_date")
529
+ expect(upToDatePreview.commitCount).toBe(0)
530
+
531
+ await writeFile(path.join(repoRoot, "app.txt"), "feature\n", "utf8")
532
+ await run(["git", "commit", "-am", "feature"], repoRoot)
533
+ await run(["git", "switch", "main"], repoRoot)
534
+
535
+ const mergeablePreview = await store.previewMergeBranch({
536
+ projectPath: repoRoot,
537
+ branch: { kind: "local", name: "feature/preview" },
538
+ })
539
+
540
+ expect(mergeablePreview.status).toBe("mergeable")
541
+ expect(mergeablePreview.commitCount).toBe(1)
542
+ expect(mergeablePreview.hasConflicts).toBe(false)
543
+ })
544
+
545
+ test("previewMergeBranch detects likely conflicts", async () => {
546
+ const repoRoot = await createRepo()
547
+ tempDirs.push(repoRoot)
548
+ await writeFile(path.join(repoRoot, "conflict.txt"), "base\n", "utf8")
549
+ await run(["git", "add", "."], repoRoot)
550
+ await run(["git", "commit", "-m", "init"], repoRoot)
551
+ await run(["git", "switch", "-c", "feature/conflict"], repoRoot)
552
+ await writeFile(path.join(repoRoot, "conflict.txt"), "feature\n", "utf8")
553
+ await run(["git", "commit", "-am", "feature"], repoRoot)
554
+ await run(["git", "switch", "main"], repoRoot)
555
+ await writeFile(path.join(repoRoot, "conflict.txt"), "main\n", "utf8")
556
+ await run(["git", "commit", "-am", "main"], repoRoot)
557
+
558
+ const store = new DiffStore(repoRoot)
559
+ await store.initialize()
560
+
561
+ const preview = await store.previewMergeBranch({
562
+ projectPath: repoRoot,
563
+ branch: { kind: "local", name: "feature/conflict" },
564
+ })
565
+
566
+ expect(preview.status).toBe("conflicts")
567
+ expect(preview.hasConflicts).toBe(true)
568
+ expect(preview.commitCount).toBe(1)
569
+ })
570
+
571
+ test("mergeBranch blocks dirty worktrees and merges clean branches", async () => {
572
+ const repoRoot = await createRepo()
573
+ tempDirs.push(repoRoot)
574
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
575
+ await run(["git", "add", "."], repoRoot)
576
+ await run(["git", "commit", "-m", "init"], repoRoot)
577
+ await run(["git", "switch", "-c", "feature/merge"], repoRoot)
578
+ await writeFile(path.join(repoRoot, "app.txt"), "feature\n", "utf8")
579
+ await run(["git", "commit", "-am", "feature"], repoRoot)
580
+ await run(["git", "switch", "main"], repoRoot)
581
+ await writeFile(path.join(repoRoot, "scratch.txt"), "dirty\n", "utf8")
582
+
583
+ const store = new DiffStore(repoRoot)
584
+ await store.initialize()
585
+
586
+ const blockedResult = await store.mergeBranch({
587
+ projectId: "project-1",
588
+ projectPath: repoRoot,
589
+ branch: { kind: "local", name: "feature/merge" },
590
+ })
591
+
592
+ expect(blockedResult).toMatchObject({
593
+ ok: false,
594
+ title: "Merge blocked",
595
+ snapshotChanged: false,
596
+ })
597
+
598
+ await rm(path.join(repoRoot, "scratch.txt"))
599
+
600
+ const mergeResult = await store.mergeBranch({
601
+ projectId: "project-1",
602
+ projectPath: repoRoot,
603
+ branch: { kind: "local", name: "feature/merge" },
604
+ })
605
+
606
+ expect(mergeResult).toMatchObject({
607
+ ok: true,
608
+ snapshotChanged: true,
609
+ })
610
+ expect((await run(["git", "branch", "--show-current"], repoRoot)).trim()).toBe("main")
611
+ expect((await run(["git", "log", "--format=%s", "-1"], repoRoot)).trim()).toBe("feature")
612
+ })
258
613
  })