diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 2ac3129b53..34762065db 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -100,6 +100,15 @@ "command": "kilo-code.new.agentManager.nextTab", "title": "Agent Manager: Next Tab", "category": "Kilo Code" + }, + { + "command": "kilo-code.new.generateCommitMessage", + "title": "Generate Commit Message", + "category": "Kilo Code (NEW)", + "icon": { + "light": "assets/icons/kilo-light.svg", + "dark": "assets/icons/kilo-dark.svg" + } } ], "keybindings": [ @@ -161,6 +170,13 @@ "when": "view == kilo-code.new.sidebarView" } ], + "scm/title": [ + { + "command": "kilo-code.new.generateCommitMessage", + "group": "navigation", + "when": "scmProvider == git" + } + ], "editor/title": [ { "command": "kilo-code.new.openInTab", diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 1c99d0aa80..b899c15eee 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -6,6 +6,7 @@ import { KiloConnectionService } from "./services/cli-backend" import { registerAutocompleteProvider } from "./services/autocomplete" import { BrowserAutomationService } from "./services/browser-automation" import { TelemetryProxy } from "./services/telemetry" +import { registerCommitMessageService } from "./services/commit-message" export function activate(context: vscode.ExtensionContext) { console.log("Kilo Code extension is now active") @@ -85,6 +86,9 @@ export function activate(context: vscode.ExtensionContext) { // Register autocomplete provider registerAutocompleteProvider(context, connectionService) + // Register commit message generation + registerCommitMessageService(context, connectionService) + // Dispose services when extension deactivates (kills the server) context.subscriptions.push({ dispose: () => { diff --git a/packages/kilo-vscode/src/services/cli-backend/http-client.ts b/packages/kilo-vscode/src/services/cli-backend/http-client.ts index d7ed331411..6871465b13 100644 --- a/packages/kilo-vscode/src/services/cli-backend/http-client.ts +++ b/packages/kilo-vscode/src/services/cli-backend/http-client.ts @@ -24,8 +24,8 @@ export class HttpClient { constructor(config: ServerConfig) { this.baseUrl = config.baseUrl - // Auth header format: Basic base64("opencode:password") - // NOTE: The CLI server expects a non-empty username ("opencode"). Using an empty username + // Auth header format: Basic base64("kilo:password") + // NOTE: The CLI server expects a non-empty username ("kilo"). Using an empty username // (":password") results in 401 for both REST and SSE endpoints. this.authHeader = `Basic ${Buffer.from(`${this.authUsername}:${config.password}`).toString("base64")}` @@ -478,6 +478,22 @@ export class HttpClient { return this.request("GET", `/find/file?${params.toString()}`, undefined, { directory }) } + // ============================================ + // Commit Message Methods + // ============================================ + + /** + * Generate a commit message for the current diff in the given directory. + */ + async generateCommitMessage(path: string, selectedFiles?: string[], previousMessage?: string): Promise { + const result = await this.request<{ message: string }>("POST", "/commit-message", { + path, + selectedFiles, + previousMessage, + }) + return result.message + } + // ============================================ // MCP Methods // ============================================ diff --git a/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts b/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts new file mode 100644 index 0000000000..6afa163c86 --- /dev/null +++ b/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +// Mock vscode following the pattern from AutocompleteServiceManager.spec.ts +vi.mock("vscode", () => { + const disposable = { dispose: vi.fn() } + + return { + commands: { + registerCommand: vi.fn((_command: string, _callback: (...args: any[]) => any) => disposable), + }, + window: { + showErrorMessage: vi.fn(), + withProgress: vi.fn(), + }, + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/test/workspace" }, + }, + ], + }, + extensions: { + getExtension: vi.fn(), + }, + ProgressLocation: { + SourceControl: 1, + }, + Uri: { + parse: (s: string) => ({ fsPath: s }), + }, + } +}) + +import * as vscode from "vscode" +import { registerCommitMessageService } from "../index" +import type { KiloConnectionService } from "../../cli-backend/connection-service" + +describe("commit-message service", () => { + let mockContext: vscode.ExtensionContext + let mockConnectionService: KiloConnectionService + let mockHttpClient: { generateCommitMessage: ReturnType } + + beforeEach(() => { + vi.clearAllMocks() + + mockContext = { + subscriptions: [], + } as any + + mockHttpClient = { + generateCommitMessage: vi.fn().mockResolvedValue("feat: add new feature"), + } + + mockConnectionService = { + getHttpClient: vi.fn().mockReturnValue(mockHttpClient), + } as any + }) + + describe("registerCommitMessageService", () => { + it("returns an array of disposables", () => { + const disposables = registerCommitMessageService(mockContext, mockConnectionService) + + expect(Array.isArray(disposables)).toBe(true) + expect(disposables.length).toBeGreaterThan(0) + }) + + it("registers the kilo-code.new.generateCommitMessage command", () => { + registerCommitMessageService(mockContext, mockConnectionService) + + expect(vscode.commands.registerCommand).toHaveBeenCalledWith( + "kilo-code.new.generateCommitMessage", + expect.any(Function), + ) + }) + + it("pushes the command disposable to context.subscriptions", () => { + registerCommitMessageService(mockContext, mockConnectionService) + + expect(mockContext.subscriptions.length).toBe(1) + }) + }) + + describe("command execution", () => { + let commandCallback: (...args: any[]) => Promise + + beforeEach(() => { + registerCommitMessageService(mockContext, mockConnectionService) + + // Extract the registered command callback + const registerCall = vi.mocked(vscode.commands.registerCommand).mock.calls[0]! + commandCallback = registerCall[1] as (...args: any[]) => Promise + }) + + it("shows error when git extension is not found", async () => { + vi.mocked(vscode.extensions.getExtension).mockReturnValue(undefined) + + await commandCallback() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Git extension not found") + }) + + it("shows error when no git repository is found", async () => { + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), + exports: { + getAPI: () => ({ repositories: [] }), + }, + } as any) + + await commandCallback() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No Git repository found") + }) + + it("shows error when backend is not connected", async () => { + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), + exports: { + getAPI: () => ({ + repositories: [{ inputBox: { value: "" }, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + vi.mocked(mockConnectionService.getHttpClient as any).mockImplementation(() => { + throw new Error("Not connected") + }) + + await commandCallback() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Kilo backend is not connected. Please wait for the connection to establish.", + ) + }) + + it("calls generateCommitMessage on the HTTP client with repository root path", async () => { + const mockInputBox = { value: "" } + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), + exports: { + getAPI: () => ({ + repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + + // Make withProgress execute its callback + vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => { + await task({} as any, {} as any) + }) + + await commandCallback() + + expect(mockHttpClient.generateCommitMessage).toHaveBeenCalledWith("/repo", undefined, undefined) + }) + + it("sets the generated message on the repository inputBox", async () => { + const mockInputBox = { value: "" } + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), + exports: { + getAPI: () => ({ + repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + + vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => { + await task({} as any, {} as any) + }) + + await commandCallback() + + expect(mockInputBox.value).toBe("feat: add new feature") + }) + + it("shows progress in SourceControl location", async () => { + const mockInputBox = { value: "" } + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), + exports: { + getAPI: () => ({ + repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + + vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => { + await task({} as any, {} as any) + }) + + await commandCallback() + + expect(vscode.window.withProgress).toHaveBeenCalledWith( + expect.objectContaining({ + location: vscode.ProgressLocation.SourceControl, + title: "Generating commit message...", + }), + expect.any(Function), + ) + }) + }) +}) diff --git a/packages/kilo-vscode/src/services/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts new file mode 100644 index 0000000000..825f36ceef --- /dev/null +++ b/packages/kilo-vscode/src/services/commit-message/index.ts @@ -0,0 +1,79 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../cli-backend/connection-service" +import type { HttpClient } from "../cli-backend/http-client" + +let lastGeneratedMessage: string | undefined +let lastWorkspacePath: string | undefined + +interface GitRepository { + inputBox: { value: string } + rootUri: vscode.Uri +} + +interface GitAPI { + repositories: GitRepository[] +} + +interface GitExtensionExports { + getAPI(version: number): GitAPI +} + +export function registerCommitMessageService( + context: vscode.ExtensionContext, + connectionService: KiloConnectionService, +): vscode.Disposable[] { + const command = vscode.commands.registerCommand("kilo-code.new.generateCommitMessage", async () => { + const extension = vscode.extensions.getExtension("vscode.git") + if (!extension) { + vscode.window.showErrorMessage("Git extension not found") + return + } + + if (!extension.isActive) { + await extension.activate() + } + + const git = extension.exports?.getAPI(1) + const repository = git?.repositories[0] + if (!repository) { + vscode.window.showErrorMessage("No Git repository found") + return + } + + let client: HttpClient | undefined + try { + client = connectionService.getHttpClient() + } catch { + vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.") + return + } + if (!client) { + vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.") + return + } + + const path = repository.rootUri.fsPath + + const previousMessage = lastWorkspacePath === path ? lastGeneratedMessage : undefined + + await vscode.window + .withProgress( + { location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." }, + async () => { + const message = await client.generateCommitMessage(path, undefined, previousMessage) + repository.inputBox.value = message + lastGeneratedMessage = message + lastWorkspacePath = path + console.log("[Kilo New] Commit message generated successfully") + }, + ) + .then(undefined, (error: unknown) => { + const msg = error instanceof Error ? error.message : String(error) + console.error("[Kilo New] Failed to generate commit message:", msg) + vscode.window.showErrorMessage(`Failed to generate commit message: ${msg}`) + }) + }) + + context.subscriptions.push(command) + return [command] +} diff --git a/packages/opencode/src/commit-message/__tests__/generate.test.ts b/packages/opencode/src/commit-message/__tests__/generate.test.ts new file mode 100644 index 0000000000..7459e73272 --- /dev/null +++ b/packages/opencode/src/commit-message/__tests__/generate.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test" +import type { GitContext } from "../types" + +// Mock dependencies before importing the module under test + +let mockGitContext: GitContext = { + 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() + }) + }) +}) diff --git a/packages/opencode/src/commit-message/__tests__/git-context.test.ts b/packages/opencode/src/commit-message/__tests__/git-context.test.ts new file mode 100644 index 0000000000..a10b05a16c --- /dev/null +++ b/packages/opencode/src/commit-message/__tests__/git-context.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, test, beforeEach } from "bun:test" + +// Mock Bun.spawnSync before importing the module under test +const spawnSyncResults: Record = {} + +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([]) + }) + }) +}) diff --git a/packages/opencode/src/commit-message/generate.ts b/packages/opencode/src/commit-message/generate.ts new file mode 100644 index 0000000000..f5321ace7b --- /dev/null +++ b/packages/opencode/src/commit-message/generate.ts @@ -0,0 +1,178 @@ +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 an expert Git commit message generator that creates conventional commit messages based on staged changes. Analyze the provided git diff output and generate an appropriate conventional commit message following the specification. + +## Conventional Commits Format +Generate commit messages following this exact structure: +\`\`\` +[optional scope]: + +[optional body] + +[optional footer(s)] +\`\`\` + +### Core Types (Required) +- **feat**: New feature or functionality (MINOR version bump) +- **fix**: Bug fix or error correction (PATCH version bump) + +### Additional Types (Extended) +- **docs**: Documentation changes only +- **style**: Code style changes (whitespace, formatting, semicolons, etc.) +- **refactor**: Code refactoring without feature changes or bug fixes +- **perf**: Performance improvements +- **test**: Adding or fixing tests +- **build**: Build system or external dependency changes +- **ci**: CI/CD configuration changes +- **chore**: Maintenance tasks, tooling changes +- **revert**: Reverting previous commits + +### Scope Guidelines +- Use parentheses: \`feat(api):\`, \`fix(ui):\` +- Common scopes: \`api\`, \`ui\`, \`auth\`, \`db\`, \`config\`, \`deps\`, \`docs\` +- For monorepos: package or module names +- Keep scope concise and lowercase + +### Description Rules +- Use imperative mood ("add" not "added" or "adds") +- Start with lowercase letter +- No period at the end +- Maximum 72 characters +- Be concise but descriptive + +### Body Guidelines (Optional) +- Start one blank line after description +- Explain the "what" and "why", not the "how" +- Wrap at 72 characters per line +- Use for complex changes requiring explanation + +### Footer Guidelines (Optional) +- Start one blank line after body +- **Breaking Changes**: \`BREAKING CHANGE: description\` + +## Analysis Instructions +When analyzing staged changes: +1. Determine Primary Type based on the nature of changes +2. Identify Scope from modified directories or modules +3. Craft Description focusing on the most significant change +4. Determine if there are Breaking Changes +5. For complex changes, include a detailed body explaining what and why +6. Add appropriate footers for issue references or breaking changes + +For significant changes, include a detailed body explaining the changes. + +Return ONLY the commit message in the conventional format, 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 { + 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, + } + + let userMessage = buildUserMessage(ctx) + if (request.previousMessage) { + userMessage = `IMPORTANT: Generate a COMPLETELY DIFFERENT commit message from the previous one. The previous message was: "${request.previousMessage}". Use a different type, scope, or description approach.\n\n${userMessage}` + } + + 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: userMessage, + }, + ], + abort: new AbortController().signal, + sessionID: "commit-message", + system: [], + retries: 3, + }) + + const result = await stream.text + log.info("generated", { message: result }) + + return { message: clean(result) } +} diff --git a/packages/opencode/src/commit-message/git-context.ts b/packages/opencode/src/commit-message/git-context.ts new file mode 100644 index 0000000000..3b97f44c58 --- /dev/null +++ b/packages/opencode/src/commit-message/git-context.ts @@ -0,0 +1,230 @@ +import type { GitContext, FileChange } from "./types" + +const LOCK_FILES = new Set([ + // --- JavaScript / Node.js --- + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "shrinkwrap.yaml", + "bun.lockb", + "bun.lock", + ".pnp.js", + ".pnp.cjs", + "jspm.lock", + + // --- Python --- + "Pipfile.lock", + "poetry.lock", + "pdm.lock", + ".pdm-lock.toml", + "uv.lock", + "conda-lock.yml", + "pylock.toml", + + // --- Ruby --- + "Gemfile.lock", + + // --- PHP --- + "composer.lock", + + // --- Java / JVM --- + "gradle.lockfile", + "lockfile.json", + "dependency-lock.json", + "dependency-reduced-pom.xml", + "coursier.lock", + + // --- Scala --- + "build.sbt.lock", + + // --- .NET --- + "packages.lock.json", + "paket.lock", + "project.assets.json", + + // --- Rust --- + "Cargo.lock", + + // --- Go --- + "go.sum", + "Gopkg.lock", + "glide.lock", + + // --- Zig --- + "build.zig.zon.lock", + + // --- OCaml --- + "dune.lock", + "opam.lock", + + // --- Swift / iOS --- + "Package.resolved", + "Podfile.lock", + "Cartfile.resolved", + + // --- Dart / Flutter --- + "pubspec.lock", + + // --- Elixir / Erlang --- + "mix.lock", + "rebar.lock", + + // --- Haskell --- + "stack.yaml.lock", + "cabal.project.freeze", + + // --- Elm --- + "exact-dependencies.json", + + // --- Crystal --- + "shard.lock", + + // --- Julia --- + "Manifest.toml", + "JuliaManifest.toml", + + // --- R --- + "renv.lock", + "packrat.lock", + + // --- Nim --- + "nimble.lock", + + // --- D --- + "dub.selections.json", + + // --- Lua --- + "rocks.lock", + + // --- Perl --- + "carton.lock", + "cpanfile.snapshot", + + // --- C/C++ --- + "conan.lock", + "vcpkg-lock.json", + + // --- Infrastructure as Code --- + ".terraform.lock.hcl", + "Berksfile.lock", + "Puppetfile.lock", + "MODULE.bazel.lock", + + // --- Nix --- + "flake.lock", + + // --- Deno --- + "deno.lock", + + // --- DevContainers --- + "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 git(args: string[], cwd: string): string { + const result = Bun.spawnSync(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + return result.stdout.toString().trimEnd() +} + +function parseNameStatus(output: string): Array<{ status: string; path: string }> { + if (!output) return [] + return output.split("\n").map((line) => { + const [status, ...rest] = line.split("\t") + let path: string + if (status!.startsWith("R")) { + // Rename: rest = ["old.ts", "new.ts"], use the new path + path = rest[1] ?? rest[0] + } else { + path = 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 } + }) +} + +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 { + 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 } +} diff --git a/packages/opencode/src/commit-message/index.ts b/packages/opencode/src/commit-message/index.ts new file mode 100644 index 0000000000..45fbb0f73a --- /dev/null +++ b/packages/opencode/src/commit-message/index.ts @@ -0,0 +1,2 @@ +export { generateCommitMessage } from "./generate" +export type { CommitMessageRequest, CommitMessageResponse, GitContext, FileChange } from "./types" diff --git a/packages/opencode/src/commit-message/types.ts b/packages/opencode/src/commit-message/types.ts new file mode 100644 index 0000000000..135fc13a3f --- /dev/null +++ b/packages/opencode/src/commit-message/types.ts @@ -0,0 +1,29 @@ +export interface CommitMessageRequest { + /** Workspace/repo path */ + path: string + /** Optional subset of files to include */ + selectedFiles?: string[] + /** Previously generated message — when set, the LLM is asked to produce a different one */ + previousMessage?: 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" + path: string + /** Diff content, or placeholder for binary/untracked files */ + diff: string +} diff --git a/packages/opencode/src/server/routes/commit-message.ts b/packages/opencode/src/server/routes/commit-message.ts new file mode 100644 index 0000000000..8ebc8d8bd5 --- /dev/null +++ b/packages/opencode/src/server/routes/commit-message.ts @@ -0,0 +1,44 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import z from "zod" +import { generateCommitMessage } from "../../commit-message" +import { lazy } from "../../util/lazy" +import { errors } from "../error" + +export const CommitMessageRoutes = lazy(() => + new Hono().post( + "/", + describeRoute({ + summary: "Generate commit message", + description: "Generate a commit message using AI based on the current git diff.", + operationId: "commitMessage.generate", + responses: { + 200: { + description: "Generated commit message", + content: { + "application/json": { + schema: resolver(z.object({ message: z.string() })), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + path: z.string().meta({ description: "Workspace/repo path" }), + selectedFiles: z.array(z.string()).optional().meta({ description: "Optional subset of files to include" }), + previousMessage: z + .string() + .optional() + .meta({ description: "Previously generated message — triggers regeneration with a different result" }), + }), + ), + async (c) => { + const body = c.req.valid("json") + const result = await generateCommitMessage(body) + return c.json({ message: result.message }) + }, + ), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 8bf922dfca..e883480e40 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -38,6 +38,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status" import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" +import { CommitMessageRoutes } from "./routes/commit-message" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -235,6 +236,7 @@ export namespace Server { .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/telemetry", TelemetryRoutes()) // kilocode_change + .route("/commit-message", CommitMessageRoutes()) // kilocode_change // kilocode_change start - Kilo Gateway routes .route( "/kilo",