kanna-code 0.22.0 → 0.23.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.
- package/dist/client/assets/index-DADxOnBF.css +32 -0
- package/dist/client/assets/index-y8BVCflz.js +2506 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/server/diff-store.test.ts +133 -2
- package/src/server/diff-store.ts +136 -1
- package/src/server/generate-commit-message.test.ts +2 -0
- package/src/server/generate-commit-message.ts +2 -2
- package/src/server/ws-router.test.ts +196 -14
- package/src/server/ws-router.ts +43 -1
- package/src/shared/protocol.ts +2 -0
- package/src/shared/types.ts +1 -0
- package/dist/client/assets/index-1BGyFTrZ.js +0 -2508
- package/dist/client/assets/index-DKD9EePK.css +0 -32
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-y8BVCflz.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DADxOnBF.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.
|
|
4
|
+
"version": "0.23.1",
|
|
5
5
|
"description": "A beautiful web UI for Claude Code",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
69
69
|
"@tailwindcss/postcss": "^4.1.18",
|
|
70
70
|
"@tailwindcss/typography": "^0.5.19",
|
|
71
|
+
"@tanstack/react-virtual": "^3.13.23",
|
|
71
72
|
"@types/bun": "latest",
|
|
72
73
|
"@types/node": "^24.10.1",
|
|
73
74
|
"@types/qrcode": "^1.5.6",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
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 { DiffStore } from "./diff-store"
|
|
5
|
+
import { appendGitIgnoreEntry, DiffStore } from "./diff-store"
|
|
6
6
|
|
|
7
7
|
async function run(command: string[], cwd: string) {
|
|
8
8
|
const process = Bun.spawn(command, {
|
|
@@ -54,6 +54,7 @@ describe("DiffStore", () => {
|
|
|
54
54
|
expect(snapshot.status).toBe("ready")
|
|
55
55
|
expect(snapshot.files).toHaveLength(1)
|
|
56
56
|
expect(snapshot.files[0]?.path).toBe("app.txt")
|
|
57
|
+
expect(snapshot.files[0]?.isUntracked).toBe(false)
|
|
57
58
|
expect(snapshot.files[0]?.patch).toContain("-base")
|
|
58
59
|
expect(snapshot.files[0]?.patch).toContain("+changed")
|
|
59
60
|
})
|
|
@@ -123,5 +124,135 @@ describe("DiffStore", () => {
|
|
|
123
124
|
expect(snapshot.files).toHaveLength(1)
|
|
124
125
|
expect(snapshot.files[0]?.path).toBe("after.txt")
|
|
125
126
|
expect(snapshot.files[0]?.changeType).toBe("renamed")
|
|
127
|
+
expect(snapshot.files[0]?.isUntracked).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("marks untracked files so they can be ignored", async () => {
|
|
131
|
+
const repoRoot = await createRepo()
|
|
132
|
+
tempDirs.push(repoRoot)
|
|
133
|
+
await writeFile(path.join(repoRoot, "tracked.txt"), "base\n", "utf8")
|
|
134
|
+
await run(["git", "add", "."], repoRoot)
|
|
135
|
+
await run(["git", "commit", "-m", "init"], repoRoot)
|
|
136
|
+
await writeFile(path.join(repoRoot, "scratch.log"), "tmp\n", "utf8")
|
|
137
|
+
|
|
138
|
+
const store = new DiffStore(repoRoot)
|
|
139
|
+
await store.initialize()
|
|
140
|
+
await store.refreshSnapshot("chat-1", repoRoot)
|
|
141
|
+
|
|
142
|
+
const snapshot = store.getSnapshot("chat-1")
|
|
143
|
+
expect(snapshot.files).toHaveLength(1)
|
|
144
|
+
expect(snapshot.files[0]).toMatchObject({
|
|
145
|
+
path: "scratch.log",
|
|
146
|
+
changeType: "added",
|
|
147
|
+
isUntracked: true,
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("discardFile reverts a tracked modified file", async () => {
|
|
152
|
+
const repoRoot = await createRepo()
|
|
153
|
+
tempDirs.push(repoRoot)
|
|
154
|
+
await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
|
|
155
|
+
await run(["git", "add", "."], repoRoot)
|
|
156
|
+
await run(["git", "commit", "-m", "init"], repoRoot)
|
|
157
|
+
await writeFile(path.join(repoRoot, "app.txt"), "changed\n", "utf8")
|
|
158
|
+
|
|
159
|
+
const store = new DiffStore(repoRoot)
|
|
160
|
+
await store.initialize()
|
|
161
|
+
await store.refreshSnapshot("chat-1", repoRoot)
|
|
162
|
+
await store.discardFile({
|
|
163
|
+
chatId: "chat-1",
|
|
164
|
+
projectPath: repoRoot,
|
|
165
|
+
path: "app.txt",
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(await readFile(path.join(repoRoot, "app.txt"), "utf8")).toBe("base\n")
|
|
169
|
+
expect(store.getSnapshot("chat-1").files).toHaveLength(0)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test("discardFile deletes an untracked file", async () => {
|
|
173
|
+
const repoRoot = await createRepo()
|
|
174
|
+
tempDirs.push(repoRoot)
|
|
175
|
+
await writeFile(path.join(repoRoot, "tracked.txt"), "base\n", "utf8")
|
|
176
|
+
await run(["git", "add", "."], repoRoot)
|
|
177
|
+
await run(["git", "commit", "-m", "init"], repoRoot)
|
|
178
|
+
await writeFile(path.join(repoRoot, "scratch.log"), "tmp\n", "utf8")
|
|
179
|
+
|
|
180
|
+
const store = new DiffStore(repoRoot)
|
|
181
|
+
await store.initialize()
|
|
182
|
+
await store.refreshSnapshot("chat-1", repoRoot)
|
|
183
|
+
await store.discardFile({
|
|
184
|
+
chatId: "chat-1",
|
|
185
|
+
projectPath: repoRoot,
|
|
186
|
+
path: "scratch.log",
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
expect(await Bun.file(path.join(repoRoot, "scratch.log")).exists()).toBe(false)
|
|
190
|
+
expect(store.getSnapshot("chat-1").files).toHaveLength(0)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test("discardFile reverts a renamed file", async () => {
|
|
194
|
+
const repoRoot = await createRepo()
|
|
195
|
+
tempDirs.push(repoRoot)
|
|
196
|
+
await writeFile(path.join(repoRoot, "before.txt"), "same\n", "utf8")
|
|
197
|
+
await run(["git", "add", "."], repoRoot)
|
|
198
|
+
await run(["git", "commit", "-m", "init"], repoRoot)
|
|
199
|
+
await run(["git", "mv", "before.txt", "after.txt"], repoRoot)
|
|
200
|
+
|
|
201
|
+
const store = new DiffStore(repoRoot)
|
|
202
|
+
await store.initialize()
|
|
203
|
+
await store.refreshSnapshot("chat-1", repoRoot)
|
|
204
|
+
await store.discardFile({
|
|
205
|
+
chatId: "chat-1",
|
|
206
|
+
projectPath: repoRoot,
|
|
207
|
+
path: "after.txt",
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
expect(await Bun.file(path.join(repoRoot, "before.txt")).exists()).toBe(true)
|
|
211
|
+
expect(await Bun.file(path.join(repoRoot, "after.txt")).exists()).toBe(false)
|
|
212
|
+
expect(store.getSnapshot("chat-1").files).toHaveLength(0)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test("ignoreFile appends a .gitignore entry once", async () => {
|
|
216
|
+
const repoRoot = await createRepo()
|
|
217
|
+
tempDirs.push(repoRoot)
|
|
218
|
+
await writeFile(path.join(repoRoot, "tracked.txt"), "base\n", "utf8")
|
|
219
|
+
await run(["git", "add", "."], repoRoot)
|
|
220
|
+
await run(["git", "commit", "-m", "init"], repoRoot)
|
|
221
|
+
await writeFile(path.join(repoRoot, "scratch.log"), "tmp\n", "utf8")
|
|
222
|
+
|
|
223
|
+
const store = new DiffStore(repoRoot)
|
|
224
|
+
await store.initialize()
|
|
225
|
+
await store.refreshSnapshot("chat-1", repoRoot)
|
|
226
|
+
await store.ignoreFile({
|
|
227
|
+
chatId: "chat-1",
|
|
228
|
+
projectPath: repoRoot,
|
|
229
|
+
path: "scratch.log",
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
expect(await readFile(path.join(repoRoot, ".gitignore"), "utf8")).toBe("scratch.log\n")
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test("appendGitIgnoreEntry does not duplicate an existing identical entry", () => {
|
|
236
|
+
expect(appendGitIgnoreEntry("scratch.log\n", "scratch.log")).toBe("scratch.log\n")
|
|
237
|
+
expect(appendGitIgnoreEntry("scratch.log", "scratch.log")).toBe("scratch.log\n")
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test("ignoreFile rejects tracked files", async () => {
|
|
241
|
+
const repoRoot = await createRepo()
|
|
242
|
+
tempDirs.push(repoRoot)
|
|
243
|
+
await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
|
|
244
|
+
await run(["git", "add", "."], repoRoot)
|
|
245
|
+
await run(["git", "commit", "-m", "init"], repoRoot)
|
|
246
|
+
await writeFile(path.join(repoRoot, "app.txt"), "changed\n", "utf8")
|
|
247
|
+
|
|
248
|
+
const store = new DiffStore(repoRoot)
|
|
249
|
+
await store.initialize()
|
|
250
|
+
await store.refreshSnapshot("chat-1", repoRoot)
|
|
251
|
+
|
|
252
|
+
await expect(store.ignoreFile({
|
|
253
|
+
chatId: "chat-1",
|
|
254
|
+
projectPath: repoRoot,
|
|
255
|
+
path: "app.txt",
|
|
256
|
+
})).rejects.toThrow("Only untracked files can be ignored from the diff viewer")
|
|
126
257
|
})
|
|
127
258
|
})
|
package/src/server/diff-store.ts
CHANGED
|
@@ -42,6 +42,7 @@ interface DirtyPathEntry {
|
|
|
42
42
|
path: string
|
|
43
43
|
previousPath?: string
|
|
44
44
|
changeType: ChatDiffFile["changeType"]
|
|
45
|
+
isUntracked: boolean
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
async function fileExists(filePath: string) {
|
|
@@ -167,9 +168,10 @@ function parseStatusPaths(output: string): DirtyPathEntry[] {
|
|
|
167
168
|
const statusCode = line.slice(0, 2)
|
|
168
169
|
const value = line.slice(3)
|
|
169
170
|
if (!value) continue
|
|
171
|
+
const isUntracked = statusCode === "??"
|
|
170
172
|
const isRename = statusCode.includes("R")
|
|
171
173
|
const isDelete = statusCode.includes("D")
|
|
172
|
-
const isAdd = statusCode.includes("A") ||
|
|
174
|
+
const isAdd = statusCode.includes("A") || isUntracked
|
|
173
175
|
const changeType: ChatDiffFile["changeType"] = isRename
|
|
174
176
|
? "renamed"
|
|
175
177
|
: isDelete
|
|
@@ -185,6 +187,7 @@ function parseStatusPaths(output: string): DirtyPathEntry[] {
|
|
|
185
187
|
path: nextPath,
|
|
186
188
|
previousPath: previousPath || undefined,
|
|
187
189
|
changeType,
|
|
190
|
+
isUntracked,
|
|
188
191
|
})
|
|
189
192
|
}
|
|
190
193
|
continue
|
|
@@ -193,6 +196,7 @@ function parseStatusPaths(output: string): DirtyPathEntry[] {
|
|
|
193
196
|
entries.push({
|
|
194
197
|
path: value,
|
|
195
198
|
changeType,
|
|
199
|
+
isUntracked,
|
|
196
200
|
})
|
|
197
201
|
}
|
|
198
202
|
return entries.sort((left, right) => left.path.localeCompare(right.path))
|
|
@@ -289,6 +293,7 @@ async function computeCurrentFiles(repoRoot: string, baseCommit: string | null):
|
|
|
289
293
|
files.push({
|
|
290
294
|
path: relativePath,
|
|
291
295
|
changeType: entry.changeType,
|
|
296
|
+
isUntracked: entry.isUntracked,
|
|
292
297
|
patch,
|
|
293
298
|
mimeType,
|
|
294
299
|
size,
|
|
@@ -306,6 +311,64 @@ function normalizeRepoRelativePath(inputPath: string) {
|
|
|
306
311
|
return normalized
|
|
307
312
|
}
|
|
308
313
|
|
|
314
|
+
async function findDirtyPath(repoRoot: string, relativePath: string) {
|
|
315
|
+
const dirtyPaths = await listDirtyPaths(repoRoot)
|
|
316
|
+
return dirtyPaths.find((entry) => entry.path === relativePath)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function discardAddedPath(repoRoot: string, repoHasHead: boolean, relativePath: string) {
|
|
320
|
+
if (repoHasHead) {
|
|
321
|
+
const resetResult = await runGit(["reset", "--quiet", "HEAD", "--", relativePath], repoRoot)
|
|
322
|
+
if (resetResult.exitCode !== 0) {
|
|
323
|
+
throw new Error(formatGitFailure(resetResult) || "Failed to unstage added file")
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
const rmCachedResult = await runGit(["rm", "--cached", "--force", "--", relativePath], repoRoot)
|
|
327
|
+
if (rmCachedResult.exitCode !== 0) {
|
|
328
|
+
throw new Error(formatGitFailure(rmCachedResult) || "Failed to unstage added file")
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function discardRenamedPath(repoRoot: string, entry: DirtyPathEntry) {
|
|
334
|
+
if (!entry.previousPath) {
|
|
335
|
+
throw new Error(`Missing previous path for renamed file: ${entry.path}`)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const resetResult = await runGit(["reset", "--quiet", "HEAD", "--", entry.path], repoRoot)
|
|
339
|
+
if (resetResult.exitCode !== 0) {
|
|
340
|
+
throw new Error(formatGitFailure(resetResult) || "Failed to unstage renamed file")
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const restoreResult = await runGit(["restore", "--staged", "--worktree", "--source=HEAD", "--", entry.previousPath], repoRoot)
|
|
344
|
+
if (restoreResult.exitCode !== 0) {
|
|
345
|
+
throw new Error(formatGitFailure(restoreResult) || "Failed to restore renamed file")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await rm(path.join(repoRoot, entry.path), { recursive: true, force: true })
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function appendGitIgnoreEntry(currentContents: string | null, entry: string) {
|
|
352
|
+
const normalizedContents = currentContents ?? ""
|
|
353
|
+
const existingEntries = normalizedContents
|
|
354
|
+
.split(/\r?\n/u)
|
|
355
|
+
.map((line) => line.trim())
|
|
356
|
+
.filter(Boolean)
|
|
357
|
+
|
|
358
|
+
if (existingEntries.includes(entry)) {
|
|
359
|
+
return normalizedContents.length > 0 && !normalizedContents.endsWith("\n")
|
|
360
|
+
? `${normalizedContents}\n`
|
|
361
|
+
: normalizedContents
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const prefix = normalizedContents.length === 0
|
|
365
|
+
? ""
|
|
366
|
+
: normalizedContents.endsWith("\n")
|
|
367
|
+
? normalizedContents
|
|
368
|
+
: `${normalizedContents}\n`
|
|
369
|
+
return `${prefix}${entry}\n`
|
|
370
|
+
}
|
|
371
|
+
|
|
309
372
|
export class DiffStore {
|
|
310
373
|
private readonly states = new Map<string, StoredChatDiffState>()
|
|
311
374
|
|
|
@@ -454,4 +517,76 @@ export class DiffStore {
|
|
|
454
517
|
snapshotChanged,
|
|
455
518
|
} satisfies DiffCommitResult
|
|
456
519
|
}
|
|
520
|
+
|
|
521
|
+
async discardFile(args: {
|
|
522
|
+
chatId: string
|
|
523
|
+
projectPath: string
|
|
524
|
+
path: string
|
|
525
|
+
}) {
|
|
526
|
+
const relativePath = normalizeRepoRelativePath(args.path)
|
|
527
|
+
const repo = await resolveRepo(args.projectPath)
|
|
528
|
+
if (!repo) {
|
|
529
|
+
throw new Error("Project is not in a git repository")
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const entry = await findDirtyPath(repo.repoRoot, relativePath)
|
|
533
|
+
if (!entry) {
|
|
534
|
+
throw new Error(`File is no longer changed: ${relativePath}`)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (entry.isUntracked) {
|
|
538
|
+
await rm(path.join(repo.repoRoot, entry.path), { recursive: true, force: true })
|
|
539
|
+
} else if (entry.changeType === "added") {
|
|
540
|
+
await discardAddedPath(repo.repoRoot, repo.baseCommit !== null, entry.path)
|
|
541
|
+
await rm(path.join(repo.repoRoot, entry.path), { recursive: true, force: true })
|
|
542
|
+
} else if (entry.changeType === "renamed") {
|
|
543
|
+
if (!repo.baseCommit) {
|
|
544
|
+
throw new Error("Cannot discard a rename before the repository has an initial commit")
|
|
545
|
+
}
|
|
546
|
+
await discardRenamedPath(repo.repoRoot, entry)
|
|
547
|
+
} else {
|
|
548
|
+
if (!repo.baseCommit) {
|
|
549
|
+
throw new Error("Cannot discard tracked changes before the repository has an initial commit")
|
|
550
|
+
}
|
|
551
|
+
const restoreResult = await runGit(["restore", "--staged", "--worktree", "--source=HEAD", "--", entry.path], repo.repoRoot)
|
|
552
|
+
if (restoreResult.exitCode !== 0) {
|
|
553
|
+
throw new Error(formatGitFailure(restoreResult) || "Failed to discard file changes")
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
snapshotChanged: await this.refreshSnapshot(args.chatId, args.projectPath),
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async ignoreFile(args: {
|
|
563
|
+
chatId: string
|
|
564
|
+
projectPath: string
|
|
565
|
+
path: string
|
|
566
|
+
}) {
|
|
567
|
+
const relativePath = normalizeRepoRelativePath(args.path)
|
|
568
|
+
const repo = await resolveRepo(args.projectPath)
|
|
569
|
+
if (!repo) {
|
|
570
|
+
throw new Error("Project is not in a git repository")
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const entry = await findDirtyPath(repo.repoRoot, relativePath)
|
|
574
|
+
if (!entry) {
|
|
575
|
+
throw new Error(`File is no longer changed: ${relativePath}`)
|
|
576
|
+
}
|
|
577
|
+
if (!entry.isUntracked) {
|
|
578
|
+
throw new Error("Only untracked files can be ignored from the diff viewer")
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const gitignorePath = path.join(repo.repoRoot, ".gitignore")
|
|
582
|
+
const currentContents = await readFile(gitignorePath, "utf8").catch(() => null)
|
|
583
|
+
const nextContents = appendGitIgnoreEntry(currentContents, relativePath)
|
|
584
|
+
if (nextContents !== currentContents) {
|
|
585
|
+
await writeFile(gitignorePath, nextContents, "utf8")
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
snapshotChanged: await this.refreshSnapshot(args.chatId, args.projectPath),
|
|
590
|
+
}
|
|
591
|
+
}
|
|
457
592
|
}
|
|
@@ -11,6 +11,7 @@ describe("generateCommitMessageDetailed", () => {
|
|
|
11
11
|
files: [{
|
|
12
12
|
path: "app.ts",
|
|
13
13
|
changeType: "modified",
|
|
14
|
+
isUntracked: false,
|
|
14
15
|
patch: "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@\n-old\n+new\n",
|
|
15
16
|
}],
|
|
16
17
|
},
|
|
@@ -38,6 +39,7 @@ describe("generateCommitMessageDetailed", () => {
|
|
|
38
39
|
files: [{
|
|
39
40
|
path: "src/feature.ts",
|
|
40
41
|
changeType: "modified",
|
|
42
|
+
isUntracked: false,
|
|
41
43
|
patch: "diff --git a/src/feature.ts b/src/feature.ts\n",
|
|
42
44
|
}],
|
|
43
45
|
},
|
|
@@ -62,11 +62,11 @@ function buildCommitMessagePrompt(args: {
|
|
|
62
62
|
const combinedPatch = args.files.map((file) => file.patch).join("\n\n")
|
|
63
63
|
|
|
64
64
|
return [
|
|
65
|
-
"Generate a
|
|
65
|
+
"Generate a git commit message for the selected changes.",
|
|
66
66
|
"Return JSON with keys: subject, body.",
|
|
67
67
|
"Rules:",
|
|
68
68
|
"- subject must be imperative, under 72 chars, and have no trailing period",
|
|
69
|
-
"- body may be an empty string or
|
|
69
|
+
"- body may be an empty string or 3-5 bullet points",
|
|
70
70
|
"- capture the primary user-visible or developer-visible change",
|
|
71
71
|
"",
|
|
72
72
|
`Branch: ${args.branchName ?? "current branch"}`,
|