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.
- package/README.md +6 -2
- package/dist/client/assets/index-Hy4LEj8j.css +32 -0
- package/dist/client/assets/index-Tg96DNSK.js +2554 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/diff-store.test.ts +382 -27
- package/src/server/diff-store.ts +1293 -30
- package/src/server/generate-commit-message.test.ts +0 -2
- package/src/server/generate-commit-message.ts +9 -4
- package/src/server/keybindings.test.ts +12 -0
- package/src/server/keybindings.ts +3 -0
- package/src/server/read-models.test.ts +1 -2
- package/src/server/read-models.ts +1 -4
- package/src/server/ws-router.test.ts +277 -8
- package/src/server/ws-router.ts +124 -50
- package/src/shared/protocol.ts +56 -0
- package/src/shared/types.ts +119 -13
- package/dist/client/assets/index-DADxOnBF.css +0 -32
- package/dist/client/assets/index-y8BVCflz.js +0 -2506
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
@@ -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("
|
|
57
|
+
await store.refreshSnapshot("project-1", repoRoot)
|
|
52
58
|
|
|
53
|
-
const snapshot = store.
|
|
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]?.
|
|
59
|
-
expect(snapshot.files[0]?.
|
|
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("
|
|
77
|
+
await store.refreshSnapshot("project-1", root)
|
|
69
78
|
|
|
70
|
-
expect(store.
|
|
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("
|
|
100
|
+
await store.refreshSnapshot("project-1", repoRoot)
|
|
91
101
|
|
|
92
102
|
await store.commitFiles({
|
|
93
|
-
|
|
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.
|
|
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("
|
|
161
|
+
await store.refreshSnapshot("project-1", repoRoot)
|
|
121
162
|
|
|
122
|
-
const snapshot = store.
|
|
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("
|
|
181
|
+
await store.refreshSnapshot("project-1", repoRoot)
|
|
141
182
|
|
|
142
|
-
const snapshot = store.
|
|
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("
|
|
202
|
+
await store.refreshSnapshot("project-1", repoRoot)
|
|
162
203
|
await store.discardFile({
|
|
163
|
-
|
|
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.
|
|
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("
|
|
223
|
+
await store.refreshSnapshot("project-1", repoRoot)
|
|
183
224
|
await store.discardFile({
|
|
184
|
-
|
|
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.
|
|
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("
|
|
244
|
+
await store.refreshSnapshot("project-1", repoRoot)
|
|
204
245
|
await store.discardFile({
|
|
205
|
-
|
|
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.
|
|
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("
|
|
266
|
+
await store.refreshSnapshot("project-1", repoRoot)
|
|
226
267
|
await store.ignoreFile({
|
|
227
|
-
|
|
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("
|
|
321
|
+
await store.refreshSnapshot("project-1", repoRoot)
|
|
251
322
|
|
|
252
323
|
await expect(store.ignoreFile({
|
|
253
|
-
|
|
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
|
})
|