581bbbb319
* feat(cli,vscode): add custom commit message prompt setting Support user-configurable system prompt for AI commit message generation. Adds a dedicated Commit Message tab in VS Code settings with a toggle to override the built-in conventional commits prompt. * fix: add missing kilocode_change annotation and fix i18n formatting * refactor: move Config reading to route handler, add textarea max-height Move kilo-specific Config.get() from shared generate.ts to the route handler. The generator now accepts an optional prompt param instead. Cap textarea height at 300px with scroll for long prompts. * fix: resolve annotation gaps and prompt clear serialization bug Add kilocode_change markers to JSDoc comment and extend block to cover the generateCommitMessage call. Use empty string instead of undefined when clearing the prompt so JSON serialization persists the removal to disk. * refactor(cli): move commit-message module to kilocode paths Move src/commit-message/ to src/kilocode/commit-message/, route to src/kilocode/server/routes/, and tests to test/kilocode/commit-message/. Extract CommitMessageSchema to KilocodeConfig and reference it from the main Config.Info. Removes all kilocode_change markers from moved files since they are now in exempt paths. * test(cli): fix moved commit-message tests
485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
import { describe, expect, test, beforeEach, mock } from "bun:test"
|
|
|
|
// Mock Bun.spawnSync via mock.module so it integrates properly with bun:test
|
|
// and doesn't conflict with other test files that mock "../git-context".
|
|
const spawnSyncResults: Record<string, string> = {}
|
|
|
|
function setGitOutput(args: string, output: string) {
|
|
spawnSyncResults[args] = output
|
|
}
|
|
|
|
function clearGitOutputs() {
|
|
for (const key of Object.keys(spawnSyncResults)) {
|
|
delete spawnSyncResults[key]
|
|
}
|
|
}
|
|
|
|
// Override the git-context module with a version that uses our mock spawnSync.
|
|
// This avoids conflicts with generate.test.ts which also mocks this module.
|
|
mock.module("../../../src/kilocode/commit-message/git-context", () => {
|
|
function git(args: string[], cwd: string): string {
|
|
const key = args.join(" ")
|
|
return spawnSyncResults[key] ?? ""
|
|
}
|
|
|
|
const LOCK_FILES = new Set([
|
|
"package-lock.json",
|
|
"npm-shrinkwrap.json",
|
|
"yarn.lock",
|
|
"pnpm-lock.yaml",
|
|
"shrinkwrap.yaml",
|
|
"bun.lockb",
|
|
"bun.lock",
|
|
".pnp.js",
|
|
".pnp.cjs",
|
|
"jspm.lock",
|
|
"Pipfile.lock",
|
|
"poetry.lock",
|
|
"pdm.lock",
|
|
".pdm-lock.toml",
|
|
"uv.lock",
|
|
"conda-lock.yml",
|
|
"pylock.toml",
|
|
"Gemfile.lock",
|
|
"composer.lock",
|
|
"gradle.lockfile",
|
|
"lockfile.json",
|
|
"dependency-lock.json",
|
|
"dependency-reduced-pom.xml",
|
|
"coursier.lock",
|
|
"build.sbt.lock",
|
|
"packages.lock.json",
|
|
"paket.lock",
|
|
"project.assets.json",
|
|
"Cargo.lock",
|
|
"go.sum",
|
|
"Gopkg.lock",
|
|
"glide.lock",
|
|
"build.zig.zon.lock",
|
|
"dune.lock",
|
|
"opam.lock",
|
|
"Package.resolved",
|
|
"Podfile.lock",
|
|
"Cartfile.resolved",
|
|
"pubspec.lock",
|
|
"mix.lock",
|
|
"rebar.lock",
|
|
"stack.yaml.lock",
|
|
"cabal.project.freeze",
|
|
"exact-dependencies.json",
|
|
"shard.lock",
|
|
"Manifest.toml",
|
|
"JuliaManifest.toml",
|
|
"renv.lock",
|
|
"packrat.lock",
|
|
"nimble.lock",
|
|
"dub.selections.json",
|
|
"rocks.lock",
|
|
"carton.lock",
|
|
"cpanfile.snapshot",
|
|
"conan.lock",
|
|
"vcpkg-lock.json",
|
|
".terraform.lock.hcl",
|
|
"Berksfile.lock",
|
|
"Puppetfile.lock",
|
|
"MODULE.bazel.lock",
|
|
"flake.lock",
|
|
"deno.lock",
|
|
"devcontainer.lock.json",
|
|
])
|
|
|
|
const MAX_DIFF_LENGTH = 4000
|
|
|
|
function isLockFile(filepath: string): boolean {
|
|
const name = filepath.split("/").pop() ?? filepath
|
|
return LOCK_FILES.has(name)
|
|
}
|
|
|
|
function parseNameStatus(output: string): Array<{ status: string; path: string }> {
|
|
if (!output) return []
|
|
return output.split("\n").map((line) => {
|
|
const [status, ...rest] = line.split("\t")
|
|
const path = status!.startsWith("R") ? (rest[1] ?? rest[0]) : rest.join("\t")
|
|
return { status: status!, path }
|
|
})
|
|
}
|
|
|
|
function parsePorcelain(output: string): Array<{ status: string; path: string }> {
|
|
if (!output) return []
|
|
return output
|
|
.split("\n")
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => {
|
|
const xy = line.slice(0, 2)
|
|
const filepath = line.slice(3)
|
|
return { status: xy.trim(), path: filepath }
|
|
})
|
|
}
|
|
|
|
type FileStatus = "added" | "modified" | "deleted" | "renamed"
|
|
|
|
function mapStatus(code: string): FileStatus {
|
|
if (code.startsWith("R")) return "renamed"
|
|
if (code === "A" || code === "??" || code === "?") return "added"
|
|
if (code === "D") return "deleted"
|
|
if (code === "M") return "modified"
|
|
return "modified"
|
|
}
|
|
|
|
function isUntracked(code: string): boolean {
|
|
return code === "??" || code === "?"
|
|
}
|
|
|
|
async function getGitContext(repoPath: string, selectedFiles?: string[]) {
|
|
const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
|
|
const log = git(["log", "--oneline", "-5"], repoPath)
|
|
const recentCommits = log ? log.split("\n") : []
|
|
|
|
const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
|
|
const useStaged = staged.length > 0
|
|
const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
|
|
|
|
const selected = selectedFiles ? new Set(selectedFiles) : undefined
|
|
|
|
const files: Array<{ status: FileStatus; path: string; diff: string }> = []
|
|
for (const entry of raw) {
|
|
if (isLockFile(entry.path)) continue
|
|
if (selected && !selected.has(entry.path)) continue
|
|
|
|
const status = mapStatus(entry.status)
|
|
const untracked = isUntracked(entry.status)
|
|
|
|
let diff: string
|
|
if (untracked) {
|
|
diff = `New untracked file: ${entry.path}`
|
|
} else if (status === "deleted") {
|
|
diff = useStaged
|
|
? git(["diff", "--cached", "--", entry.path], repoPath)
|
|
: git(["diff", "--", entry.path], repoPath)
|
|
} else {
|
|
const raw = useStaged
|
|
? git(["diff", "--cached", "--", entry.path], repoPath)
|
|
: git(["diff", "--", entry.path], repoPath)
|
|
if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
|
|
diff = `Binary file ${entry.path} has been modified`
|
|
} else {
|
|
diff = raw
|
|
}
|
|
}
|
|
|
|
if (diff.length > MAX_DIFF_LENGTH) {
|
|
diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
|
|
}
|
|
|
|
files.push({ status, path: entry.path, diff })
|
|
}
|
|
|
|
return { branch, recentCommits, files }
|
|
}
|
|
|
|
return { getGitContext }
|
|
})
|
|
|
|
import { getGitContext } from "../../../src/kilocode/commit-message/git-context"
|
|
|
|
describe("commit-message.git-context", () => {
|
|
beforeEach(() => {
|
|
clearGitOutputs()
|
|
// Defaults
|
|
setGitOutput("branch --show-current", "main")
|
|
setGitOutput("log --oneline -5", "abc1234 initial commit")
|
|
setGitOutput("diff --name-status --cached", "")
|
|
setGitOutput("status --porcelain", "")
|
|
})
|
|
|
|
// NOTE: git() trims stdout, which eats the leading space of the first
|
|
// porcelain line. We use staged (--name-status) tests for path-sensitive
|
|
// assertions and only use porcelain for behavior tests where this is acceptable.
|
|
|
|
describe("lock file filtering", () => {
|
|
test("filters out package-lock.json from staged changes", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/index.ts\nM\tpackage-lock.json")
|
|
setGitOutput("diff --cached -- src/index.ts", "+console.log('hello')")
|
|
setGitOutput("diff --cached -- package-lock.json", "+lots of lock content")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.path).toBe("src/index.ts")
|
|
})
|
|
|
|
test("filters out yarn.lock from staged changes", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/app.ts\nM\tyarn.lock")
|
|
setGitOutput("diff --cached -- src/app.ts", "+import x")
|
|
setGitOutput("diff --cached -- yarn.lock", "+lock data")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.path).toBe("src/app.ts")
|
|
})
|
|
|
|
test("filters out pnpm-lock.yaml from staged changes", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\treadme.md\nM\tpnpm-lock.yaml")
|
|
setGitOutput("diff --cached -- pnpm-lock.yaml", "+lock")
|
|
setGitOutput("diff --cached -- readme.md", "+docs")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.path).toBe("readme.md")
|
|
})
|
|
|
|
test("filters lock files in subdirectories", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\tpackages/api/package-lock.json\nM\tpackages/api/src/index.ts")
|
|
setGitOutput("diff --cached -- packages/api/package-lock.json", "+lock stuff")
|
|
setGitOutput("diff --cached -- packages/api/src/index.ts", "+code")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.path).toBe("packages/api/src/index.ts")
|
|
})
|
|
|
|
test("filters out bun.lockb, go.sum, Cargo.lock, poetry.lock", async () => {
|
|
setGitOutput(
|
|
"diff --name-status --cached",
|
|
"M\tbun.lockb\nM\tgo.sum\nM\tCargo.lock\nM\tpoetry.lock\nM\tsrc/main.rs",
|
|
)
|
|
setGitOutput("diff --cached -- bun.lockb", "binary")
|
|
setGitOutput("diff --cached -- go.sum", "+hash")
|
|
setGitOutput("diff --cached -- Cargo.lock", "+lock")
|
|
setGitOutput("diff --cached -- poetry.lock", "+lock")
|
|
setGitOutput("diff --cached -- src/main.rs", "+fn main() {}")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.path).toBe("src/main.rs")
|
|
})
|
|
})
|
|
|
|
describe("status parsing", () => {
|
|
test("parses staged added files", async () => {
|
|
setGitOutput("diff --name-status --cached", "A\tsrc/new-file.ts")
|
|
setGitOutput("diff --cached -- src/new-file.ts", "+new content")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.status).toBe("added")
|
|
expect(ctx.files[0]!.path).toBe("src/new-file.ts")
|
|
})
|
|
|
|
test("parses staged modified files", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/existing.ts")
|
|
setGitOutput("diff --cached -- src/existing.ts", "+changed line")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.status).toBe("modified")
|
|
})
|
|
|
|
test("parses staged deleted files", async () => {
|
|
setGitOutput("diff --name-status --cached", "D\tsrc/removed.ts")
|
|
setGitOutput("diff --cached -- src/removed.ts", "-deleted content")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.status).toBe("deleted")
|
|
})
|
|
|
|
test("parses staged renamed files", async () => {
|
|
setGitOutput("diff --name-status --cached", "R100\told-name.ts\tnew-name.ts")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.status).toBe("renamed")
|
|
})
|
|
|
|
test("parses untracked files from porcelain", async () => {
|
|
setGitOutput("status --porcelain", "?? src/brand-new.ts")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.status).toBe("added")
|
|
expect(ctx.files[0]!.diff).toBe("New untracked file: src/brand-new.ts")
|
|
})
|
|
|
|
test("parses porcelain modified files", async () => {
|
|
// Use staged to avoid porcelain trim edge case
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/changed.ts")
|
|
setGitOutput("diff --cached -- src/changed.ts", "+line")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.status).toBe("modified")
|
|
})
|
|
|
|
test("prefers staged changes over unstaged", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/staged.ts")
|
|
setGitOutput("diff --cached -- src/staged.ts", "+staged change")
|
|
// unstaged also exists but should be ignored when staged is present
|
|
setGitOutput("status --porcelain", " M src/unstaged.ts")
|
|
setGitOutput("diff -- src/unstaged.ts", "+unstaged change")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.path).toBe("src/staged.ts")
|
|
})
|
|
|
|
test("mapStatus returns 'modified' for unknown codes", async () => {
|
|
setGitOutput("diff --name-status --cached", "X\tsrc/weird.ts")
|
|
setGitOutput("diff --cached -- src/weird.ts", "+stuff")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files[0]!.status).toBe("modified")
|
|
})
|
|
})
|
|
|
|
describe("diff truncation", () => {
|
|
test("truncates diffs exceeding 4000 characters", async () => {
|
|
const longDiff = "x".repeat(5000)
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/big.ts")
|
|
setGitOutput("diff --cached -- src/big.ts", longDiff)
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.diff.length).toBeLessThan(5000)
|
|
expect(ctx.files[0]!.diff).toContain("... [truncated]")
|
|
// 4000 chars + "\n... [truncated]"
|
|
expect(ctx.files[0]!.diff.length).toBe(4000 + "\n... [truncated]".length)
|
|
})
|
|
|
|
test("does not truncate diffs at exactly 4000 characters", async () => {
|
|
const exactDiff = "y".repeat(4000)
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/exact.ts")
|
|
setGitOutput("diff --cached -- src/exact.ts", exactDiff)
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files[0]!.diff).toBe(exactDiff)
|
|
expect(ctx.files[0]!.diff).not.toContain("... [truncated]")
|
|
})
|
|
|
|
test("does not truncate diffs under 4000 characters", async () => {
|
|
const shortDiff = "z".repeat(100)
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/small.ts")
|
|
setGitOutput("diff --cached -- src/small.ts", shortDiff)
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files[0]!.diff).toBe(shortDiff)
|
|
})
|
|
})
|
|
|
|
describe("binary file detection", () => {
|
|
test("detects 'Binary files' in diff output", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\tassets/logo.png")
|
|
setGitOutput("diff --cached -- assets/logo.png", "Binary files a/assets/logo.png and b/assets/logo.png differ")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.diff).toBe("Binary file assets/logo.png has been modified")
|
|
})
|
|
|
|
test("detects 'GIT binary patch' in diff output", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\tassets/icon.ico")
|
|
setGitOutput("diff --cached -- assets/icon.ico", "GIT binary patch\nliteral 1234\ndata...")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(1)
|
|
expect(ctx.files[0]!.diff).toBe("Binary file assets/icon.ico has been modified")
|
|
})
|
|
|
|
test("does not flag normal diffs as binary", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/code.ts")
|
|
setGitOutput("diff --cached -- src/code.ts", "+const x = 1")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files[0]!.diff).toBe("+const x = 1")
|
|
})
|
|
})
|
|
|
|
describe("selected files filtering", () => {
|
|
test("only includes files in selectedFiles set", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts\nM\tsrc/c.ts")
|
|
setGitOutput("diff --cached -- src/a.ts", "+a")
|
|
setGitOutput("diff --cached -- src/b.ts", "+b")
|
|
setGitOutput("diff --cached -- src/c.ts", "+c")
|
|
|
|
const ctx = await getGitContext("/repo", ["src/a.ts", "src/c.ts"])
|
|
|
|
expect(ctx.files).toHaveLength(2)
|
|
const paths = ctx.files.map((f) => f.path)
|
|
expect(paths).toContain("src/a.ts")
|
|
expect(paths).toContain("src/c.ts")
|
|
expect(paths).not.toContain("src/b.ts")
|
|
})
|
|
|
|
test("includes all files when selectedFiles is undefined", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts")
|
|
setGitOutput("diff --cached -- src/a.ts", "+a")
|
|
setGitOutput("diff --cached -- src/b.ts", "+b")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.files).toHaveLength(2)
|
|
})
|
|
|
|
test("returns empty files when selectedFiles has no matches", async () => {
|
|
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts")
|
|
setGitOutput("diff --cached -- src/a.ts", "+a")
|
|
|
|
const ctx = await getGitContext("/repo", ["src/nonexistent.ts"])
|
|
|
|
expect(ctx.files).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe("branch and recent commits", () => {
|
|
test("returns current branch name", async () => {
|
|
setGitOutput("branch --show-current", "feature/my-branch")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.branch).toBe("feature/my-branch")
|
|
})
|
|
|
|
test("falls back to HEAD when branch is empty", async () => {
|
|
setGitOutput("branch --show-current", "")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.branch).toBe("HEAD")
|
|
})
|
|
|
|
test("returns recent commits as array", async () => {
|
|
setGitOutput("log --oneline -5", "abc1234 first\ndef5678 second\nghi9012 third")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.recentCommits).toEqual(["abc1234 first", "def5678 second", "ghi9012 third"])
|
|
})
|
|
|
|
test("returns empty array when no commits", async () => {
|
|
setGitOutput("log --oneline -5", "")
|
|
|
|
const ctx = await getGitContext("/repo")
|
|
|
|
expect(ctx.recentCommits).toEqual([])
|
|
})
|
|
})
|
|
})
|