feat: add commit message generation feature
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, test, mock, beforeEach } from "bun:test"
|
||||
|
||||
// Mock dependencies before importing the module under test
|
||||
|
||||
let mockGitContext = {
|
||||
branch: "main",
|
||||
recentCommits: ["abc1234 initial commit"],
|
||||
files: [
|
||||
{ status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" },
|
||||
],
|
||||
}
|
||||
|
||||
mock.module("../git-context", () => ({
|
||||
getGitContext: async () => mockGitContext,
|
||||
}))
|
||||
|
||||
let mockStreamText = "feat(src): add hello world logging"
|
||||
|
||||
mock.module("@/provider/provider", () => ({
|
||||
Provider: {
|
||||
defaultModel: async () => ({ providerID: "test", modelID: "test-model" }),
|
||||
getSmallModel: async () => ({
|
||||
providerID: "test",
|
||||
id: "test-small-model",
|
||||
}),
|
||||
getModel: async () => ({ providerID: "test", id: "test-model" }),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/session/llm", () => ({
|
||||
LLM: {
|
||||
stream: async () => ({
|
||||
text: Promise.resolve(mockStreamText),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/agent/agent", () => ({
|
||||
Agent: {},
|
||||
}))
|
||||
|
||||
mock.module("@/util/log", () => ({
|
||||
Log: {
|
||||
create: () => ({
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
import { generateCommitMessage } from "../generate"
|
||||
|
||||
describe("commit-message.generate", () => {
|
||||
beforeEach(() => {
|
||||
mockGitContext = {
|
||||
branch: "main",
|
||||
recentCommits: ["abc1234 initial commit"],
|
||||
files: [
|
||||
{ status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" },
|
||||
],
|
||||
}
|
||||
mockStreamText = "feat(src): add hello world logging"
|
||||
})
|
||||
|
||||
describe("prompt construction", () => {
|
||||
test("passes path to getGitContext", async () => {
|
||||
const result = await generateCommitMessage({ path: "/my/repo" })
|
||||
// If getGitContext is called, it returns our mock context and generates a message
|
||||
expect(result.message).toBeTruthy()
|
||||
})
|
||||
|
||||
test("generates message from git context with multiple files", async () => {
|
||||
mockGitContext = {
|
||||
branch: "feature/api",
|
||||
recentCommits: ["abc feat: add api", "def fix: typo"],
|
||||
files: [
|
||||
{ status: "added" as const, path: "src/api.ts", diff: "+export function api() {}" },
|
||||
{ status: "modified" as const, path: "src/index.ts", diff: "+import { api } from './api'" },
|
||||
],
|
||||
}
|
||||
mockStreamText = "feat(api): add api module"
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("feat(api): add api module")
|
||||
})
|
||||
})
|
||||
|
||||
describe("response cleaning", () => {
|
||||
test("strips code block markers from response", async () => {
|
||||
mockStreamText = "```\nfeat: add feature\n```"
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("feat: add feature")
|
||||
})
|
||||
|
||||
test("strips code block markers with language tag", async () => {
|
||||
mockStreamText = "```text\nfix(auth): resolve token refresh\n```"
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("fix(auth): resolve token refresh")
|
||||
})
|
||||
|
||||
test("strips surrounding double quotes", async () => {
|
||||
mockStreamText = '"feat: add new feature"'
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("feat: add new feature")
|
||||
})
|
||||
|
||||
test("strips surrounding single quotes", async () => {
|
||||
mockStreamText = "'fix: resolve bug'"
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("fix: resolve bug")
|
||||
})
|
||||
|
||||
test("strips whitespace around the message", async () => {
|
||||
mockStreamText = " \n chore: update deps \n "
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("chore: update deps")
|
||||
})
|
||||
|
||||
test("strips code blocks AND quotes together", async () => {
|
||||
mockStreamText = '```\n"refactor: simplify logic"\n```'
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("refactor: simplify logic")
|
||||
})
|
||||
|
||||
test("returns clean message when no markers present", async () => {
|
||||
mockStreamText = "docs: update readme"
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("docs: update readme")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error on no changes", () => {
|
||||
test("throws when no git changes are found", async () => {
|
||||
mockGitContext = {
|
||||
branch: "main",
|
||||
recentCommits: [],
|
||||
files: [],
|
||||
}
|
||||
|
||||
await expect(generateCommitMessage({ path: "/repo" })).rejects.toThrow(
|
||||
"No changes found to generate a commit message for",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("selectedFiles pass-through", () => {
|
||||
test("passes selectedFiles to getGitContext", async () => {
|
||||
// This verifies the function doesn't crash when selectedFiles is provided
|
||||
const result = await generateCommitMessage({
|
||||
path: "/repo",
|
||||
selectedFiles: ["src/a.ts"],
|
||||
})
|
||||
expect(result.message).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, expect, test, beforeEach } from "bun:test"
|
||||
|
||||
// Mock Bun.spawnSync before importing the module under test
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
// Replace global Bun.spawnSync — the git() helper in git-context.ts calls
|
||||
// result.stdout.toString().trim(), so we return a Buffer and let git() trim.
|
||||
Bun.spawnSync = ((cmd: string[], _opts?: any) => {
|
||||
const args = cmd.slice(1).join(" ")
|
||||
const output = spawnSyncResults[args] ?? ""
|
||||
return {
|
||||
stdout: Buffer.from(output),
|
||||
stderr: Buffer.from(""),
|
||||
exitCode: 0,
|
||||
}
|
||||
}) as typeof Bun.spawnSync
|
||||
|
||||
import { getGitContext } from "../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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Log } from "@/util/log"
|
||||
import type { CommitMessageRequest, CommitMessageResponse, GitContext } from "./types"
|
||||
import { getGitContext } from "./git-context"
|
||||
|
||||
const log = Log.create({ service: "commit-message" })
|
||||
|
||||
const SYSTEM_PROMPT = `You are a commit message generator. Generate a concise commit message following the Conventional Commits format.
|
||||
|
||||
Format: type(scope): description
|
||||
|
||||
Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
||||
|
||||
Rules:
|
||||
- Keep the subject line under 72 characters
|
||||
- Use imperative mood ("add feature" not "added feature")
|
||||
- No period at the end of the subject line
|
||||
- The scope is optional but encouraged
|
||||
- Output ONLY the commit message, nothing else`
|
||||
|
||||
function buildUserMessage(ctx: GitContext): string {
|
||||
const fileList = ctx.files.map((f) => `${f.status} ${f.path}`).join("\n")
|
||||
const diffs = ctx.files
|
||||
.filter((f) => f.diff)
|
||||
.map((f) => `--- ${f.path} ---\n${f.diff}`)
|
||||
.join("\n\n")
|
||||
|
||||
return `Generate a commit message for the following changes:
|
||||
|
||||
Branch: ${ctx.branch}
|
||||
Recent commits:
|
||||
${ctx.recentCommits.join("\n")}
|
||||
|
||||
Changed files:
|
||||
${fileList}
|
||||
|
||||
Diffs:
|
||||
${diffs}`
|
||||
}
|
||||
|
||||
function clean(text: string): string {
|
||||
let result = text.trim()
|
||||
// Strip code block markers
|
||||
if (result.startsWith("```")) {
|
||||
const first = result.indexOf("\n")
|
||||
if (first !== -1) {
|
||||
result = result.slice(first + 1)
|
||||
}
|
||||
}
|
||||
if (result.endsWith("```")) {
|
||||
result = result.slice(0, -3)
|
||||
}
|
||||
result = result.trim()
|
||||
// Strip surrounding quotes
|
||||
if ((result.startsWith('"') && result.endsWith('"')) || (result.startsWith("'") && result.endsWith("'"))) {
|
||||
result = result.slice(1, -1)
|
||||
}
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
export async function generateCommitMessage(request: CommitMessageRequest): Promise<CommitMessageResponse> {
|
||||
const ctx = await getGitContext(request.path, request.selectedFiles)
|
||||
if (ctx.files.length === 0) {
|
||||
throw new Error("No changes found to generate a commit message for")
|
||||
}
|
||||
|
||||
log.info("generating", {
|
||||
branch: ctx.branch,
|
||||
files: ctx.files.length,
|
||||
})
|
||||
|
||||
const defaultModel = await Provider.defaultModel()
|
||||
const model =
|
||||
(await Provider.getSmallModel(defaultModel.providerID)) ??
|
||||
(await Provider.getModel(defaultModel.providerID, defaultModel.modelID))
|
||||
|
||||
const agent: Agent.Info = {
|
||||
name: "commit-message",
|
||||
mode: "primary",
|
||||
hidden: true,
|
||||
options: {},
|
||||
permission: [],
|
||||
prompt: SYSTEM_PROMPT,
|
||||
temperature: 0.3,
|
||||
}
|
||||
|
||||
const stream = await LLM.stream({
|
||||
agent,
|
||||
user: {
|
||||
id: "commit-message",
|
||||
sessionID: "commit-message",
|
||||
role: "user",
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
completed: Date.now(),
|
||||
},
|
||||
} as any,
|
||||
tools: {},
|
||||
model,
|
||||
small: true,
|
||||
messages: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: buildUserMessage(ctx),
|
||||
},
|
||||
],
|
||||
abort: new AbortController().signal,
|
||||
sessionID: "commit-message",
|
||||
system: [],
|
||||
retries: 3,
|
||||
})
|
||||
|
||||
const result = await stream.text
|
||||
log.info("generated", { message: result })
|
||||
|
||||
return { message: clean(result) }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { GitContext, FileChange } from "./types"
|
||||
|
||||
const LOCK_FILES = new Set([
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"Cargo.lock",
|
||||
"poetry.lock",
|
||||
"composer.lock",
|
||||
"Gemfile.lock",
|
||||
"go.sum",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
"uv.lock",
|
||||
"Pipfile.lock",
|
||||
"flake.lock",
|
||||
"packages.lock.json",
|
||||
"project.assets.json",
|
||||
"paket.lock",
|
||||
"pubspec.lock",
|
||||
"Package.resolved",
|
||||
"Podfile.lock",
|
||||
"shrinkwrap.yaml",
|
||||
])
|
||||
|
||||
const MAX_DIFF_LENGTH = 4000
|
||||
|
||||
function isLockFile(filepath: string): boolean {
|
||||
const name = filepath.split("/").pop() ?? filepath
|
||||
return LOCK_FILES.has(name)
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string): string {
|
||||
const result = Bun.spawnSync(["git", ...args], {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
return result.stdout.toString().trim()
|
||||
}
|
||||
|
||||
function parseNameStatus(output: string): Array<{ status: string; path: string }> {
|
||||
if (!output) return []
|
||||
return output.split("\n").map((line) => {
|
||||
const [status, ...rest] = line.split("\t")
|
||||
return { status: status!, path: rest.join("\t") }
|
||||
})
|
||||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
||||
function mapStatus(code: string): FileChange["status"] {
|
||||
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 === "?"
|
||||
}
|
||||
|
||||
export async function getGitContext(repoPath: string, selectedFiles?: string[]): Promise<GitContext> {
|
||||
const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
|
||||
const log = git(["log", "--oneline", "-5"], repoPath)
|
||||
const recentCommits = log ? log.split("\n") : []
|
||||
|
||||
// Check staged files first
|
||||
const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
|
||||
const useStaged = staged.length > 0
|
||||
|
||||
// Fall back to all changes if nothing staged
|
||||
const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
|
||||
|
||||
const selected = selectedFiles ? new Set(selectedFiles) : undefined
|
||||
|
||||
const files: FileChange[] = []
|
||||
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)
|
||||
|
||||
// Detect binary files
|
||||
if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
|
||||
diff = `Binary file ${entry.path} has been modified`
|
||||
} else {
|
||||
diff = raw
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate large diffs
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { generateCommitMessage } from "./generate"
|
||||
export type { CommitMessageRequest, CommitMessageResponse, GitContext, FileChange } from "./types"
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface CommitMessageRequest {
|
||||
/** Workspace/repo path */
|
||||
path: string
|
||||
/** Optional subset of files to include */
|
||||
selectedFiles?: string[]
|
||||
}
|
||||
|
||||
export interface CommitMessageResponse {
|
||||
/** The generated commit message */
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface GitContext {
|
||||
/** Current branch name */
|
||||
branch: string
|
||||
/** Last 5 commit summaries */
|
||||
recentCommits: string[]
|
||||
/** File changes with status and diff content */
|
||||
files: FileChange[]
|
||||
}
|
||||
|
||||
export interface FileChange {
|
||||
status: "added" | "modified" | "deleted" | "renamed" | "untracked"
|
||||
path: string
|
||||
/** Diff content, or placeholder for binary/untracked files */
|
||||
diff: string
|
||||
}
|
||||
Reference in New Issue
Block a user