diff --git a/tui/internal/tui/gitactions.go b/tui/internal/tui/gitactions.go index 3ba51c5..de235d5 100644 --- a/tui/internal/tui/gitactions.go +++ b/tui/internal/tui/gitactions.go @@ -14,6 +14,10 @@ import ( type commitRunMsg struct{ err error } +const commitSkill = "/generate-git-commit-message" + +const commitPrompt = commitSkill + "\n先加载这条技能,按它的格式根据当前未提交改动生成提交说明,再 git add 并 git commit。\n用 git status 和 git diff(含暂存区)看清改了什么。\n不要把本指令、按钮名或任何操作口令原样当作 commit message。\n没有改动就停下来,不要空提交。不要 push,不要改无关文件。" + type mergePreviewResultMsg struct { issueNumber int result git.MergePreviewResult @@ -30,7 +34,7 @@ func commitRunCmd() tea.Cmd { return commitRunMsg{err: err} } _, err = cc.SpawnClaude(ctx, cc.Opts{ - Prompt: "提交下代码", + Prompt: commitPrompt, Cwd: root, TimeoutMs: 30 * 60 * 1000, }) diff --git a/tui/internal/tui/gitactions_test.go b/tui/internal/tui/gitactions_test.go index 57604ab..d759987 100644 --- a/tui/internal/tui/gitactions_test.go +++ b/tui/internal/tui/gitactions_test.go @@ -184,3 +184,18 @@ func TestCommitRun_InFlightGuard(t *testing.T) { t.Fatal("commitRunMsg must clear commitRunning") } } + +func TestCommitPrompt_NotLiteralCommand(t *testing.T) { + if !strings.HasPrefix(commitPrompt, commitSkill+"\n") { + t.Fatalf("commitPrompt must start with %q, got %q", commitSkill, commitPrompt) + } + if commitPrompt == "提交下代码" { + t.Fatal("commitPrompt must not be the old literal command") + } + if !strings.Contains(commitPrompt, "不要把本指令") { + t.Errorf("commitPrompt should forbid using the instruction as the commit message, got %q", commitPrompt) + } + if !strings.Contains(commitPrompt, "git commit") { + t.Errorf("commitPrompt should ask for git commit, got %q", commitPrompt) + } +} diff --git a/vscode/src/cc/commitPrompt.test.ts b/vscode/src/cc/commitPrompt.test.ts new file mode 100644 index 0000000..2c24a6d --- /dev/null +++ b/vscode/src/cc/commitPrompt.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { COMMIT_PROMPT, COMMIT_SKILL } from './commitPrompt' + +describe('commit prompt', () => { + it('开头就是生成提交说明的技能命令', () => { + expect(COMMIT_SKILL).toBe('/generate-git-commit-message') + expect(COMMIT_PROMPT.startsWith(`${COMMIT_SKILL}\n`)).toBe(true) + }) + + it('不是「提交下代码」这种会被原样拿去当提交说明的短口令', () => { + expect(COMMIT_PROMPT).not.toBe('提交下代码') + expect(COMMIT_PROMPT).not.toContain('提交下代码') + }) + + it('要求按改动写提交说明,并禁止把指令本身当成 commit message', () => { + expect(COMMIT_PROMPT).toContain('git commit') + expect(COMMIT_PROMPT).toContain('不要把本指令') + expect(COMMIT_PROMPT).toContain('不要空提交') + }) +}) diff --git a/vscode/src/cc/commitPrompt.ts b/vscode/src/cc/commitPrompt.ts new file mode 100644 index 0000000..bc3f663 --- /dev/null +++ b/vscode/src/cc/commitPrompt.ts @@ -0,0 +1,13 @@ +/** + * 工具栏「提交代码」发给 Claude 的一次性指令:先加载生成提交说明的技能,再按改动 commit。 + * `--bare` 下技能仍靠 `/skill-name` 解析,所以第一行必须是技能命令。 + */ +export const COMMIT_SKILL = '/generate-git-commit-message' + +export const COMMIT_PROMPT = [ + COMMIT_SKILL, + '先加载这条技能,按它的格式根据当前未提交改动生成提交说明,再 git add 并 git commit。', + '用 git status 和 git diff(含暂存区)看清改了什么。', + '不要把本指令、按钮名或任何操作口令原样当作 commit message。', + '没有改动就停下来,不要空提交。不要 push,不要改无关文件。', +].join('\n') diff --git a/vscode/src/panel/handlers/toolbar.test.ts b/vscode/src/panel/handlers/toolbar.test.ts new file mode 100644 index 0000000..f6a262e --- /dev/null +++ b/vscode/src/panel/handlers/toolbar.test.ts @@ -0,0 +1,74 @@ +import type { KanbanWebviewPanel } from '../KanbanPanel' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { COMMIT_PROMPT } from '../../cc/commitPrompt' +import { handleCommitRun } from './toolbar' + +const mocks = vi.hoisted(() => ({ + spawnClaude: vi.fn(), + getEffectiveCommitProfilePath: vi.fn(), + resolveCommitProfileForRun: vi.fn(), + readGitIdentity: vi.fn(), + toast: vi.fn(), + dismiss: vi.fn(), +})) + +vi.mock('vscode', () => ({ + workspace: { workspaceFolders: [{ uri: { fsPath: '/repo' } }] }, +})) +vi.mock('../../cc/commitProfile', () => ({ + resolveCommitProfileForRun: (...args: unknown[]) => mocks.resolveCommitProfileForRun(...args), +})) +vi.mock('../../cc/profiles', () => ({ resolveProfilePath: (p: string) => p })) +vi.mock('../../cc/spawnClaude', () => ({ spawnClaude: (...args: unknown[]) => mocks.spawnClaude(...args) })) +vi.mock('../../git/identity', () => ({ + missingGitIdentityKeys: (identity: { name: string, email: string }) => { + const missing: string[] = [] + if (!identity.name.trim()) + missing.push('user.name') + if (!identity.email.trim()) + missing.push('user.email') + return missing + }, + readGitIdentity: (...args: unknown[]) => mocks.readGitIdentity(...args), +})) +vi.mock('../../settings/store', () => ({ + getEffectiveCommitProfilePath: (...args: unknown[]) => mocks.getEffectiveCommitProfilePath(...args), +})) +vi.mock('../../webview/html', () => ({ makeNonce: () => 'n1' })) +vi.mock('./handoffFlow', () => ({ + toast: (...args: unknown[]) => mocks.toast(...args), + dismiss: (...args: unknown[]) => mocks.dismiss(...args), +})) +vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } })) +vi.mock('../../files/envLock', () => ({ findEnvFiles: vi.fn(), lockEnvFiles: vi.fn(), unlockEnvFiles: vi.fn() })) +vi.mock('../../git/gitExtension', () => ({ loadGitApi: vi.fn() })) + +function panel(): KanbanWebviewPanel { + return { + commitRunning: false, + context: {}, + postMessage: vi.fn(), + } as unknown as KanbanWebviewPanel +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.getEffectiveCommitProfilePath.mockResolvedValue('/p/deepseek-v4-pro.json') + mocks.resolveCommitProfileForRun.mockReturnValue({ ok: true, profilePath: '/p/deepseek-v4-pro.json' }) + mocks.readGitIdentity.mockResolvedValue({ name: 'me', email: 'me@example.com' }) + mocks.spawnClaude.mockResolvedValue({ resultText: 'committed', sessionId: 's', rawJson: '{}' }) + mocks.toast.mockReturnValue('toast-id') +}) + +describe('handleCommitRun', () => { + it('把写提交说明的指令交给 claude,不用「提交下代码」', async () => { + const p = panel() + await handleCommitRun(p) + expect(mocks.spawnClaude).toHaveBeenCalledOnce() + const args = mocks.spawnClaude.mock.calls[0][0] as { prompt: string, cwd: string, profilePath: string } + expect(args.prompt).toBe(COMMIT_PROMPT) + expect(args.prompt).not.toBe('提交下代码') + expect(args.cwd).toBe('/repo') + expect(args.profilePath).toBe('/p/deepseek-v4-pro.json') + }) +}) diff --git a/vscode/src/panel/handlers/toolbar.ts b/vscode/src/panel/handlers/toolbar.ts index 588480c..0350105 100644 --- a/vscode/src/panel/handlers/toolbar.ts +++ b/vscode/src/panel/handlers/toolbar.ts @@ -4,6 +4,7 @@ import { accessSync, constants } from 'node:fs' import * as path from 'node:path' import { workspace } from 'vscode' import { resolveCommitProfileForRun } from '../../cc/commitProfile' +import { COMMIT_PROMPT } from '../../cc/commitPrompt' import { resolveProfilePath } from '../../cc/profiles' import { spawnClaude } from '../../cc/spawnClaude' import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock' @@ -84,7 +85,7 @@ export async function handleCommitRun(panel: KanbanWebviewPanel): Promise // claude -p 提交流程可能跑得比较久(要 git add / 编 commit message / git // commit),所以给一个比较宽松的超时(30 min)。 const { resultText } = await spawnClaude({ - prompt: '提交下代码', + prompt: COMMIT_PROMPT, cwd: workspaceRoot, profilePath: resolution.profilePath, timeoutMs: 30 * 60 * 1000, diff --git a/vscode/webview-ui/src/hooks/useIssues.ts b/vscode/webview-ui/src/hooks/useIssues.ts index 905843a..c0432a7 100644 --- a/vscode/webview-ui/src/hooks/useIssues.ts +++ b/vscode/webview-ui/src/hooks/useIssues.ts @@ -259,7 +259,7 @@ export interface UseIssuesResult { */ commitRunning: boolean /** - * Trigger a background `claude -p "提交下代码"` run in the workspace + * Trigger a background Claude commit run in the workspace * root, gated by `commitRunning`. The extension responds with * `commit/state` messages and a `toast/show` on completion. */