From d7f97f3685d2c306558b192f9babae2c1ca47dd8 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 27 Jun 2026 19:35:39 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(M2):=20Commit=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E7=AA=97=E5=8F=A3(WebviewView=20=E8=87=AA=E7=BB=98)+CommitPipe?= =?UTF-8?q?line+AI=20=E6=8E=A5=E7=BC=9D;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- src/adapter/commit/commit-service.ts | 168 ++++++++++++ src/adapter/commit/conventional-check.ts | 24 ++ src/adapter/webview/commit-webview.ts | 312 +++++++++++++++++++++++ src/agent/commit-message.ts | 28 ++ src/agent/conflict.ts | 23 ++ src/agent/grouper.ts | 23 ++ src/agent/index.ts | 11 +- src/agent/pre-commit.ts | 11 + src/engine/commit/conventional-linter.ts | 46 ++++ src/extension.ts | 39 ++- src/shared/protocol.ts | 47 +++- 11 files changed, 709 insertions(+), 23 deletions(-) create mode 100644 src/adapter/commit/commit-service.ts create mode 100644 src/adapter/commit/conventional-check.ts create mode 100644 src/adapter/webview/commit-webview.ts create mode 100644 src/agent/commit-message.ts create mode 100644 src/agent/conflict.ts create mode 100644 src/agent/grouper.ts create mode 100644 src/engine/commit/conventional-linter.ts diff --git a/src/adapter/commit/commit-service.ts b/src/adapter/commit/commit-service.ts new file mode 100644 index 0000000..9b12b64 --- /dev/null +++ b/src/adapter/commit/commit-service.ts @@ -0,0 +1,168 @@ +import * as vscode from 'vscode'; +import { CommitPipeline } from '../../engine/commit/pipeline'; +import { CheckinResult } from '../../engine/commit/pipeline'; +import type { CheckinHook } from '../../engine/commit/pipeline'; +import { validateConventional } from '../../engine/commit/conventional-linter'; +import type { ConventionalValidation } from '../../engine/commit/conventional-linter'; +import type { Repository } from '../../types/git'; +import type { ChangeItem, GitRepositoryService } from '../git-repository-service'; +import type { + IChangelistGrouper, + ICommitMessageProvider, + IConflictResolver, + ILlmProvider, + IPreCommitInspector, +} from '../../agent'; +import { ConventionalCommitCheck } from './conventional-check'; + +export interface CommitRequest { + readonly message: string; + readonly selectedPaths: readonly string[]; + readonly amend: boolean; + readonly signoff: boolean; + readonly skipHooks: boolean; + readonly push: boolean; +} + +export interface CommitOutcome { + readonly ok: boolean; + readonly error?: string; +} + +/** Commit 流水线依赖的 AI 接缝集合(Null 实现注入,M5 替换为真实实现)。 */ +export interface AiSeams { + readonly llm: ILlmProvider; + readonly commitMessage: ICommitMessageProvider; + readonly preCommit: IPreCommitInspector; + readonly grouper: IChangelistGrouper; + readonly conflict: IConflictResolver; +} + +const RECENT_KEY = 'hyperGit.recentCommitMessages'; +const RECENT_MAX = 10; + +/** + * CommitService:编排提交流水线(对齐 IDEA checkin 流程)。 + * + * 流程:校验信息 → 解析选中文件 → stage → CommitPipeline 责任链(Checkin hook)→ commit → 可选 push。 + * 注入 5 个 AI 接缝(Null 实现),为 M5 即插即用预留。 + */ +export class CommitService implements vscode.Disposable { + private readonly pipeline: CommitPipeline; + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange: vscode.Event = this._onDidChange.event; + + constructor( + context: vscode.ExtensionContext, + private readonly service: GitRepositoryService, + private readonly workspaceState: vscode.Memento, + readonly ai: AiSeams, + ) { + const hooks: readonly CheckinHook[] = [ + new ConventionalCommitCheck(() => this.conventionalEnabled()), + ai.preCommit, + ]; + this.pipeline = new CommitPipeline(hooks); + context.subscriptions.push(this._onDidChange); + } + + get repo(): Repository | null { + return this.service.repo; + } + + validateMessage(message: string): ConventionalValidation { + return validateConventional(message); + } + + conventionalEnabled(): boolean { + return vscode.workspace.getConfiguration('hyperGit.commit').get('conventional', true); + } + + getTemplate(): string { + return vscode.workspace.getConfiguration('hyperGit.commit').get('template', '') ?? ''; + } + + getRecentMessages(): readonly string[] { + const raw = this.workspaceState.get(RECENT_KEY); + try { + return raw ? (JSON.parse(raw) as string[]) : []; + } catch { + return []; + } + } + + private pushRecent(message: string): void { + const trimmed = message.trim(); + const list = [trimmed, ...this.getRecentMessages().filter((m) => m !== trimmed)].slice(0, RECENT_MAX); + void this.workspaceState.update(RECENT_KEY, JSON.stringify(list)); + } + + /** 提交:stage 选中文件 → Checkin hook 链 → commit → 可选 push。 */ + async executeCommit(req: CommitRequest): Promise { + const repo = this.service.repo; + if (!repo) { + return { ok: false, error: '未找到 Git 仓库' }; + } + const message = req.message.trim(); + if (!message) { + return { ok: false, error: '提交信息不能为空' }; + } + + // CC 即时校验(pipeline 内的 hook 亦会拦截,此处给出明确原因) + if (this.conventionalEnabled()) { + const v = validateConventional(message); + if (v.severity === 'error') { + return { ok: false, error: v.reason ?? '提交信息不符合 Conventional Commits 规范' }; + } + } + + // 解析选中文件的绝对路径 + const changes = this.service.getChanges(); + const absPaths = this.resolveAbsolute(req.selectedPaths, changes); + if (absPaths.length === 0) { + return { ok: false, error: '未选择任何待提交文件' }; + } + + // Checkin hook 责任链 + const hookResult = await this.pipeline.run({ message, filePaths: req.selectedPaths }); + if (hookResult === CheckinResult.Cancel) { + return { ok: false, error: '提交被检查拦截(Checkin hook)' }; + } + + try { + await repo.add(absPaths); + await repo.commit(message, { amend: req.amend, signoff: req.signoff, noVerify: req.skipHooks }); + if (req.push) { + await repo.push(); + } + this.pushRecent(message); + this._onDidChange.fire(); + return { ok: true }; + } catch (e) { + return { ok: false, error: this.normalizeError(e) }; + } + } + + private resolveAbsolute(selectedPaths: readonly string[], changes: readonly ChangeItem[]): string[] { + const map = new Map(changes.map((c) => [c.relativePath, c.uri.fsPath])); + const out: string[] = []; + for (const p of selectedPaths) { + const abs = map.get(p); + if (abs) { + out.push(abs); + } + } + return out; + } + + private normalizeError(e: unknown): string { + if (e instanceof Error) { + return e.message; + } + return String(e); + } + + dispose(): void { + this._onDidChange.dispose(); + } +} diff --git a/src/adapter/commit/conventional-check.ts b/src/adapter/commit/conventional-check.ts new file mode 100644 index 0000000..58f276b --- /dev/null +++ b/src/adapter/commit/conventional-check.ts @@ -0,0 +1,24 @@ +import { CheckinResult } from '../../engine/commit/pipeline'; +import type { CheckinHook, CommitInfo } from '../../engine/commit/pipeline'; +import { validateConventional } from '../../engine/commit/conventional-linter'; + +/** + * 内置非 AI 提交检查:Conventional Commits 校验(对齐 IDEA CheckinHandler 闸门语义)。 + * + * 当配置 `hyperGit.commit.conventional` 开启时,校验失败(severity=error)阻断提交, + * 用以证明 CommitPipeline 责任链可注入并阻断(M5 的 AI 审查 hook 将以同样方式接入)。 + */ +export class ConventionalCommitCheck implements CheckinHook { + readonly name = 'conventional-commit-check'; + readonly executionOrder = 10; + + constructor(private readonly isEnabled: () => boolean) {} + + async beforeCheckin(info: CommitInfo): Promise { + if (!this.isEnabled()) { + return CheckinResult.Commit; + } + const result = validateConventional(info.message); + return result.severity === 'error' ? CheckinResult.Cancel : CheckinResult.Commit; + } +} diff --git a/src/adapter/webview/commit-webview.ts b/src/adapter/webview/commit-webview.ts new file mode 100644 index 0000000..3077cf3 --- /dev/null +++ b/src/adapter/webview/commit-webview.ts @@ -0,0 +1,312 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { getDecoration } from '../../engine/scm-mapping/status-decoration'; +import type { CommitRequest } from '../commit/commit-service'; +import type { ChangelistRegistry } from '../changelist-registry'; +import type { ChangeItem, GitRepositoryService } from '../git-repository-service'; +import type { CommitFileItem, CommitViewState, HostToWebviewMessage, WebviewToHostMessage } from '../../shared/protocol'; +import type { CommitService } from '../commit/commit-service'; + +/** + * Commit 提交窗口(WebviewView,自绘 IDEA 风格)。 + * + * 文件勾选 + 多行 Commit Message 编辑器 + Amend/sign-off/skip-hooks 选项 + Commit/Commit and Push 按钮 + + * Conventional Commits 实时校验 + 最近消息复用。选中态由 webview 端管理(host 不回写,避免覆盖用户操作)。 + */ +export class CommitWebviewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = 'hyperGit.commit'; + private view?: vscode.WebviewView; + private currentMessage = ''; + + constructor( + private readonly service: GitRepositoryService, + private readonly registry: ChangelistRegistry, + private readonly commit: CommitService, + ) {} + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = { enableScripts: true, localResourceRoots: [] }; + view.webview.html = this.renderHtml(); + view.webview.onDidReceiveMessage((msg) => this.onMessage(msg as WebviewToHostMessage)); + this.pushState(); + } + + refresh(): void { + this.pushState(); + } + + private onMessage(msg: WebviewToHostMessage): void { + switch (msg.type) { + case 'requestState': + this.pushState(); + break; + case 'messageChanged': + this.currentMessage = msg.payload.message; + this.sendValidation(); + break; + case 'commit': + void this.handleCommit(msg.payload); + break; + } + } + + private sendValidation(): void { + this.post({ type: 'conventionalValidation', payload: this.commit.validateMessage(this.currentMessage) }); + } + + private async handleCommit(payload: CommitRequest): Promise { + const result = await this.commit.executeCommit(payload); + this.post({ type: 'commitResult', payload: result }); + if (result.ok) { + this.currentMessage = ''; + this.pushState(); + } + } + + private post(message: HostToWebviewMessage): void { + this.view?.webview.postMessage(message); + } + + private buildFiles(): CommitFileItem[] { + const changes = this.service.getChanges(); + const groups = this.registry.getGroups(changes, (c) => c.relativePath); + const active = groups.find((g) => g.active) ?? groups[0]; + return (active?.items ?? []).map((c) => this.toFileItem(c)); + } + + private toFileItem(c: ChangeItem): CommitFileItem { + const decoration = getDecoration(c.status); + return { + path: c.relativePath, + label: path.basename(c.relativePath), + dir: path.dirname(c.relativePath), + status: decoration.letter, + statusName: c.status, + themeColor: decoration.themeColor, + }; + } + + private pushState(): void { + if (!this.view) { + return; + } + const state: CommitViewState = { + template: this.commit.getTemplate(), + recentMessages: this.commit.getRecentMessages(), + activeChangelistName: this.registry.getDef(this.registry.activeChangelistId)?.name ?? 'Default', + files: this.buildFiles(), + conventionalEnabled: this.commit.conventionalEnabled(), + busy: false, + }; + this.post({ type: 'state', payload: state }); + this.sendValidation(); + } + + private renderHtml(): string { + const nonce = getNonce(); + const csp = [ + 'default-src \'none\'', + 'style-src \'unsafe-inline\'', + `script-src 'nonce-${nonce}'`, + ].join('; '); + + return ` + + + + + + + +
活动 Changelist:
+
+ +
+
+ + + +
+ + +
+
+ + + +`; + } +} + +function getNonce(): string { + let text = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} diff --git a/src/agent/commit-message.ts b/src/agent/commit-message.ts new file mode 100644 index 0000000..13ac951 --- /dev/null +++ b/src/agent/commit-message.ts @@ -0,0 +1,28 @@ +/** + * AI 接缝(M5 实现):提交信息生成。 + * + * 输入 staged diff(可含历史风格、团队规范、changelist 分组意图),流式输出符合 + * Conventional Commits 的提交信息。当前 Null 实现:不生成(AI 未启用)。 + * 与内置 Copilot 的差异:注入完整 commit 流程上下文 + 回写工作流(M5)。 + */ +export interface CommitMessageInput { + readonly stagedDiff: string; + readonly recentStyle?: readonly string[]; + readonly convention?: string; +} + +export interface CommitMessageResult { + readonly message: string; + readonly confidence?: number; +} + +export interface ICommitMessageProvider { + generate(input: CommitMessageInput, onChunk: (chunk: string) => void, token: AbortSignal): Promise; +} + +/** Null 实现:AI 未启用,不生成提交信息。 */ +export class NullCommitMessageProvider implements ICommitMessageProvider { + async generate(_input: CommitMessageInput, _onChunk: (chunk: string) => void, _token: AbortSignal): Promise { + return { message: '' }; + } +} diff --git a/src/agent/conflict.ts b/src/agent/conflict.ts new file mode 100644 index 0000000..eb87f25 --- /dev/null +++ b/src/agent/conflict.ts @@ -0,0 +1,23 @@ +/** + * AI 接缝(M5 实现):冲突解决助手。 + * + * 输入三方冲突(ours/theirs/base),输出建议的合并结果(可逐块采纳)。 + * 安全红线:必须用户逐块确认,不可自动写入(对齐 VS Code 工具 prepareInvocation 确认机制)。 + */ +export interface ConflictResolution { + readonly hunk: string; + readonly suggested: string; + readonly confidence?: number; + readonly reason?: string; +} + +export interface IConflictResolver { + suggest(input: { readonly ours: string; readonly theirs: string; readonly base: string }): Promise; +} + +/** Null 实现:AI 未启用,不提供冲突建议。 */ +export class NullConflictResolver implements IConflictResolver { + async suggest(): Promise { + return []; + } +} diff --git a/src/agent/grouper.ts b/src/agent/grouper.ts new file mode 100644 index 0000000..e430788 --- /dev/null +++ b/src/agent/grouper.ts @@ -0,0 +1,23 @@ +/** + * AI 接缝(M5 实现):变更语义分组(自动 changelist 归类)。 + * + * 输入全部变更文件 + diff 摘要,输出建议的 changelist 分组(可一键应用回 changelist 模型)。 + * 当前 Null 实现:不分组。 + */ +export interface GroupSuggestion { + readonly name: string; + readonly filePaths: readonly string[]; + readonly reason?: string; + readonly suggestedMessage?: string; +} + +export interface IChangelistGrouper { + suggest(input: { readonly files: ReadonlyArray<{ readonly path: string; readonly diffSummary?: string }> }): Promise; +} + +/** Null 实现:AI 未启用,不分组。 */ +export class NullChangelistGrouper implements IChangelistGrouper { + async suggest(): Promise { + return []; + } +} diff --git a/src/agent/index.ts b/src/agent/index.ts index bb3e37e..6da5a3e 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -1,9 +1,12 @@ /** - * Agent 层(AI 接缝,预留)。 + * Agent 层(AI 接缝)。 * * 依赖 Engine 层但不依赖 Adapter 层(正交分解),确保 AI 能力可独立演进与替换 provider。 - * M5 实现:ILlmProvider / IPreCommitInspector / IChangelistGrouper / IConflictResolver + - * Chat Tools(`languageModelTools`)暴露 git 能力给任意 Agent。 + * 五大接缝:ILlmProvider / ICommitMessageProvider / IPreCommitInspector / IChangelistGrouper / IConflictResolver。 + * 当前均为 Null 实现(零 AI 依赖,未启用 AI 用户零负担);M5 替换为真实实现。 */ export type { ILlmProvider, LlmSource, LlmAvailability, LlmAvailabilityState, NullLlmProvider } from './llm-provider'; -export type { IPreCommitInspector, InspectionProblem, InspectionResult } from './pre-commit'; +export type { ICommitMessageProvider, CommitMessageInput, CommitMessageResult, NullCommitMessageProvider } from './commit-message'; +export type { IPreCommitInspector, InspectionProblem, InspectionResult, NullPreCommitInspector } from './pre-commit'; +export type { IChangelistGrouper, GroupSuggestion, NullChangelistGrouper } from './grouper'; +export type { IConflictResolver, ConflictResolution, NullConflictResolver } from './conflict'; diff --git a/src/agent/pre-commit.ts b/src/agent/pre-commit.ts index 6b23ab1..b39451b 100644 --- a/src/agent/pre-commit.ts +++ b/src/agent/pre-commit.ts @@ -1,3 +1,4 @@ +import { CheckinResult } from '../engine/commit/pipeline'; import type { CheckinHook } from '../engine/commit/pipeline'; /** @@ -25,3 +26,13 @@ export interface InspectionResult { export interface IPreCommitInspector extends CheckinHook { // M5: inspect(input: { stagedFiles; diff; context }): Promise; } + +/** Null 实现:AI 未启用,提交前检查恒放行。 */ +export class NullPreCommitInspector implements IPreCommitInspector { + readonly name = 'null-pre-commit-inspector'; + readonly executionOrder = 100; + + async beforeCheckin(): Promise { + return CheckinResult.Commit; + } +} diff --git a/src/engine/commit/conventional-linter.ts b/src/engine/commit/conventional-linter.ts new file mode 100644 index 0000000..e279a84 --- /dev/null +++ b/src/engine/commit/conventional-linter.ts @@ -0,0 +1,46 @@ +/** + * Conventional Commits 提交信息校验(纯函数,可单测)。 + * + * 规约:Conventional Commits 1.0.0。首行(subject)须形如 + * `type(scope)!: description` + * 其中 type ∈ 允许集合,scope 与 `!`(破坏性)可选,冒号后须有空格 + 非空描述。 + * 参考:https://www.conventionalcommits.org/zh-hans/v1.0.0/ + */ + +export type ConventionalSeverity = 'ok' | 'warning' | 'error'; + +export interface ConventionalValidation { + readonly severity: ConventionalSeverity; + readonly reason?: string; +} + +const ALLOWED_TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert']; +const SUBJECT_RE = new RegExp(`^(${ALLOWED_TYPES.join('|')})(\\([\\w.\\-/]+\\))?(!)?: .+`); +const SUBJECT_MAX_LENGTH = 72; + +export const ALLOWED_COMMIT_TYPES = ALLOWED_TYPES; + +/** + * 校验提交信息是否符合 Conventional Commits。 + * - error:阻断提交(空信息 / 首行格式不符)。 + * - warning:可提交但提示(主题过长等)。 + * - ok:通过。 + */ +export function validateConventional(message: string): ConventionalValidation { + const trimmed = message.trim(); + if (!trimmed) { + return { severity: 'error', reason: '提交信息不能为空' }; + } + const subject = message.split('\n', 1)[0] ?? ''; + if (!subject.trim()) { + return { severity: 'error', reason: '主题行(首行)不能为空' }; + } + if (!SUBJECT_RE.test(subject)) { + const allowed = ALLOWED_TYPES.join('/'); + return { severity: 'error', reason: `首行需形如 "type(scope): description",type ∈ ${allowed}` }; + } + if (subject.length > SUBJECT_MAX_LENGTH) { + return { severity: 'warning', reason: `主题行 ${subject.length} 字符,建议 ≤ ${SUBJECT_MAX_LENGTH}` }; + } + return { severity: 'ok' }; +} diff --git a/src/extension.ts b/src/extension.ts index eb92fb2..78481f4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,8 +1,14 @@ import * as vscode from 'vscode'; +import { NullChangelistGrouper } from './agent/grouper'; +import { NullCommitMessageProvider } from './agent/commit-message'; +import { NullConflictResolver } from './agent/conflict'; import { NullLlmProvider } from './agent/llm-provider'; +import { NullPreCommitInspector } from './agent/pre-commit'; import { registerChangesCommands } from './adapter/commands'; import { ChangelistRegistry } from './adapter/changelist-registry'; +import { CommitService } from './adapter/commit/commit-service'; import { ChangesTreeProvider, EmptyChangesProvider } from './adapter/tree/changes-tree'; +import { CommitWebviewProvider } from './adapter/webview/commit-webview'; import { getGitApi } from './adapter/git-api'; import { GitRepositoryService } from './adapter/git-repository-service'; import { createLogger } from './infra/logger'; @@ -15,7 +21,6 @@ export async function activate(context: vscode.ExtensionContext): Promise logger.info('Hyper Git activated'); const llm = new NullLlmProvider(); - context.subscriptions.push( vscode.commands.registerCommand('hyperGit.showVersion', () => { const version: string = context.extension.packageJSON.version; @@ -24,10 +29,9 @@ export async function activate(context: vscode.ExtensionContext): Promise }), ); - // M1:Git Adapter + Changes TreeView(多 changelist) const api = await getGitApi(); if (!api) { - logger.warn('vscode.git API 不可用,Changes 视图保持空状态'); + logger.warn('vscode.git API 不可用,视图保持空状态'); context.subscriptions.push(vscode.window.registerTreeDataProvider('hyperGit.changes', new EmptyChangesProvider())); return; } @@ -37,16 +41,39 @@ export async function activate(context: vscode.ExtensionContext): Promise const registry = new ChangelistRegistry(context.workspaceState, service.repoRoot ?? workspaceRoot); const tree = new ChangesTreeProvider(service, registry); + // AI 接缝注入(Null 实现,M5 替换为真实 provider) + const commit = new CommitService(context, service, context.workspaceState, { + llm, + commitMessage: new NullCommitMessageProvider(), + preCommit: new NullPreCommitInspector(), + grouper: new NullChangelistGrouper(), + conflict: new NullConflictResolver(), + }); + const commitView = new CommitWebviewProvider(service, registry, commit); + const focusCommitView = (): void => { + void vscode.commands.executeCommand('hyperGit.commit.focus'); + }; + context.subscriptions.push( service, registry, + commit, vscode.window.registerTreeDataProvider('hyperGit.changes', tree), + vscode.window.registerWebviewViewProvider(CommitWebviewProvider.viewType, commitView), ...registerChangesCommands(service, registry, tree), + vscode.commands.registerCommand('hyperGit.commit', focusCommitView), + vscode.commands.registerCommand('hyperGit.commitAndPush', focusCommitView), ); - service.onDidChange(() => tree.refresh()); - registry.onDidChange(() => tree.refresh()); + + const refreshAll = (): void => { + tree.refresh(); + commitView.refresh(); + }; + service.onDidChange(refreshAll); + registry.onDidChange(refreshAll); + commit.onDidChange(refreshAll); } export function deactivate(): void { - // 预留:M2+ 在此释放长生命周期资源。 + // 预留:M3+ 在此释放长生命周期资源。 } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index bdc4d67..4be28eb 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -1,30 +1,51 @@ /** - * Webview(Commit 窗口 / Log 图)↔ Extension Host 的消息类型契约。 + * Webview(Commit 窗口)↔ Extension Host 的消息类型契约。 * - * 【单一事实源】前端(ui/)与宿主(adapter/webview/)共同 import 本文件, - * 杜绝两侧各定义一份 message interface 造成 Split-Brain。 - * 随里程碑演进,新增消息类型在此扩展。 + * 【单一事实源】前端(webview 内联 JS)与宿主(adapter/webview/)共同遵循本契约, + * 杜绝两侧各定义一份造成 Split-Brain。随里程碑演进在此扩展。 */ +import type { ConventionalValidation, ConventionalSeverity } from '../engine/commit/conventional-linter'; + +export type { ConventionalValidation, ConventionalSeverity }; + +/** Commit 视图中的文件条目(选中态由 webview 端管理,host 不回写以避免覆盖用户操作)。 */ +export interface CommitFileItem { + readonly path: string; // 仓库相对路径(key) + readonly label: string; // basename + readonly dir: string; // dirname + readonly status: string; // FileStatus 字母(M/A/D/...) + readonly statusName: string; // 状态名(Modified/...) + readonly themeColor: string; // gitDecoration.* 主题色 id → webview 用 var(--vscode-...) +} + export interface CommitViewState { readonly template: string; readonly recentMessages: readonly string[]; - readonly fileCount: number; - readonly amendEnabled: boolean; -} - -export interface ConventionalValidation { - readonly valid: boolean; - readonly reason?: string; + readonly activeChangelistName: string; + readonly files: readonly CommitFileItem[]; + readonly conventionalEnabled: boolean; + readonly busy: boolean; } /** Host → Webview */ export type HostToWebviewMessage = | { readonly type: 'state'; readonly payload: CommitViewState } - | { readonly type: 'conventionalValidation'; readonly payload: ConventionalValidation }; + | { readonly type: 'conventionalValidation'; readonly payload: ConventionalValidation } + | { readonly type: 'commitResult'; readonly payload: { readonly ok: boolean; readonly error?: string } }; /** Webview → Host */ export type WebviewToHostMessage = | { readonly type: 'requestState' } | { readonly type: 'messageChanged'; readonly payload: { readonly message: string } } - | { readonly type: 'commit'; readonly payload: { readonly message: string; readonly amend: boolean; readonly push: boolean } }; + | { + readonly type: 'commit'; + readonly payload: { + readonly message: string; + readonly selectedPaths: readonly string[]; + readonly amend: boolean; + readonly signoff: boolean; + readonly skipHooks: boolean; + readonly push: boolean; + }; + }; From 1bd99d8da4a52d55f7746b13633bc1a110026e60 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 27 Jun 2026 19:35:39 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(M2):=20CC=20linter=20=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=E4=B8=8E=E7=9C=9F=E5=AE=9E=20git=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- tests/run-integration.js | 40 ++++++++++++++++++- tests/suite/commit-flow.test.js | 53 ++++++++++++++++++++++++++ tests/suite/extension.test.js | 4 +- tests/suite/index.js | 3 +- tests/unit/conventional-linter.test.ts | 50 ++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 tests/suite/commit-flow.test.js create mode 100644 tests/unit/conventional-linter.test.ts diff --git a/tests/run-integration.js b/tests/run-integration.js index 5106eb3..9881579 100644 --- a/tests/run-integration.js +++ b/tests/run-integration.js @@ -1,15 +1,51 @@ const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const cp = require('child_process'); const { runTests } = require('@vscode/test-electron'); +function git(args, cwd) { + cp.execFileSync('git', args, { cwd, stdio: ['ignore', 'ignore', 'ignore'] }); +} + +/** 创建带待提交变更的临时 git 仓库 fixture(供 Commit 闭环集成测试)。 */ +function createFixtureRepo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hyper-git-fixture-')); + git(['init', '-q'], dir); + git(['config', 'user.email', 'test@hyper-git.local'], dir); + git(['config', 'user.name', 'Hyper Git Test'], dir); + git(['config', 'commit.gpgsign', 'false'], dir); + fs.writeFileSync(path.join(dir, 'README.md'), '# init\n'); + git(['add', 'README.md'], dir); + git(['commit', '-q', '-m', 'chore: 初始提交'], dir); + // 制造待提交变更:修改 + 新增未跟踪 + fs.writeFileSync(path.join(dir, 'README.md'), '# init\n\n修改\n'); + fs.writeFileSync(path.join(dir, 'feature.txt'), '新功能\n'); + return dir; +} + async function main() { + let fixtureDir; try { + fixtureDir = createFixtureRepo(); const extensionDevelopmentPath = path.resolve(__dirname, '..'); const extensionTestsPath = path.resolve(__dirname, 'suite', 'index'); - // 在 Extension Development Host 内运行 Mocha 集成测试。 - await runTests({ extensionDevelopmentPath, extensionTestsPath }); + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [fixtureDir], + }); } catch (err) { console.error('集成测试失败:', err); process.exit(1); + } finally { + if (fixtureDir) { + try { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } } } diff --git a/tests/suite/commit-flow.test.js b/tests/suite/commit-flow.test.js new file mode 100644 index 0000000..cdfba45 --- /dev/null +++ b/tests/suite/commit-flow.test.js @@ -0,0 +1,53 @@ +const assert = require('assert'); +const vscode = require('vscode'); +const path = require('path'); +const cp = require('child_process'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** + * 真实 git 提交闭环集成测试: + * 通过 vscode.git 公开 API 执行 add(绝对路径)+ commit,并校验 git log。 + * 这正是 CommitService.executeCommit 委托的核心机制(路径 B:消费 vscode.git API)。 + */ +suite('Commit 流程(真实 git 操作)', function () { + this.timeout(60000); + + test('vscode.git add + commit 落库,工作区随之清洁', async function () { + const gitExt = vscode.extensions.getExtension('vscode.git'); + assert.ok(gitExt, 'vscode.git 扩展未找到'); + if (!gitExt.isActive) { + await gitExt.activate(); + } + const api = gitExt.exports.getAPI(1); + + // 等待 vscode.git 发现 fixture 仓库并扫描出变更 + let repo = api.repositories[0]; + for (let i = 0; i < 40; i++) { + repo = api.repositories[0]; + if (repo) { + try { + await repo.status(); + } catch { + /* ignore */ + } + if (repo.state.workingTreeChanges.length > 0 || repo.state.untrackedChanges.length > 0) { + break; + } + } + await sleep(500); + } + assert.ok(repo, '未发现 git 仓库(fixture 未被 vscode.git 打开)'); + const root = repo.rootUri.fsPath; + + // 与 CommitService 一致:传绝对路径(Uri.file 要求绝对) + await repo.add([path.join(root, 'README.md'), path.join(root, 'feature.txt')]); + await repo.commit('feat(test): 验证提交闭环'); + + const subject = cp.execFileSync('git', ['log', '-1', '--pretty=%s'], { cwd: root }).toString().trim(); + assert.strictEqual(subject, 'feat(test): 验证提交闭环'); + + const status = cp.execFileSync('git', ['status', '--porcelain'], { cwd: root }).toString().trim(); + assert.strictEqual(status, '', '提交后工作区应为空'); + }); +}); diff --git a/tests/suite/extension.test.js b/tests/suite/extension.test.js index 486e3f0..453ca27 100644 --- a/tests/suite/extension.test.js +++ b/tests/suite/extension.test.js @@ -6,7 +6,7 @@ const EXT_ID = 'threefish-ai.hyper-git'; suite('扩展冒烟测试', function () { this.timeout(30000); - test('扩展可激活并注册全部 M1 命令', async () => { + test('扩展可激活并注册全部 M1+M2 命令', async () => { const ext = vscode.extensions.getExtension(EXT_ID); assert.ok(ext, `扩展 ${EXT_ID} 未找到`); if (!ext.isActive) { @@ -22,6 +22,8 @@ suite('扩展冒烟测试', function () { 'hyperGit.deleteChangelist', 'hyperGit.moveChangelist', 'hyperGit.openDiff', + 'hyperGit.commit', + 'hyperGit.commitAndPush', ]) { assert.ok(commands.includes(cmd), `命令 ${cmd} 未注册`); } diff --git a/tests/suite/index.js b/tests/suite/index.js index cb4548c..9fa5dc7 100644 --- a/tests/suite/index.js +++ b/tests/suite/index.js @@ -6,8 +6,9 @@ const Mocha = require('mocha'); * 收集并运行 tests/suite 下的 *.test.js。 */ async function run() { - const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 30000 }); + const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 60000 }); mocha.addFile(path.resolve(__dirname, 'extension.test.js')); + mocha.addFile(path.resolve(__dirname, 'commit-flow.test.js')); return new Promise((resolve, reject) => { mocha.run((failures) => { diff --git a/tests/unit/conventional-linter.test.ts b/tests/unit/conventional-linter.test.ts new file mode 100644 index 0000000..2775ad6 --- /dev/null +++ b/tests/unit/conventional-linter.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { ALLOWED_COMMIT_TYPES, validateConventional } from '../../src/engine/commit/conventional-linter'; + +describe('validateConventional', () => { + it('空信息 → error', () => { + expect(validateConventional('').severity).toBe('error'); + expect(validateConventional(' ').severity).toBe('error'); + }); + + it('规范 type(scope): desc → ok', () => { + expect(validateConventional('feat(auth): 添加登录').severity).toBe('ok'); + expect(validateConventional('fix: 修复崩溃').severity).toBe('ok'); + expect(validateConventional('chore!: 破坏性变更').severity).toBe('ok'); + expect(validateConventional('refactor(parser): 重构').severity).toBe('ok'); + }); + + it('缺冒号后空格 → error', () => { + expect(validateConventional('feat:无空格').severity).toBe('error'); + }); + + it('缺冒号 → error', () => { + expect(validateConventional('feat无冒号').severity).toBe('error'); + }); + + it('未知 type → error', () => { + expect(validateConventional('unknown: 描述').severity).toBe('error'); + }); + + it('主题过长 → warning(仍可提交)', () => { + const long = 'feat: ' + 'a'.repeat(80); + const v = validateConventional(long); + expect(v.severity).toBe('warning'); + }); + + it('多行信息以首行判定', () => { + expect(validateConventional('feat: 主题\n\n正文描述').severity).toBe('ok'); + }); + + it('reason 在非 ok 时提供', () => { + const v = validateConventional('bad'); + expect(v.severity).toBe('error'); + expect(v.reason).toBeTruthy(); + }); + + it('暴露允许的 type 集合', () => { + expect(ALLOWED_COMMIT_TYPES).toContain('feat'); + expect(ALLOWED_COMMIT_TYPES).toContain('refactor'); + expect(ALLOWED_COMMIT_TYPES).toContain('revert'); + }); +}); From e87181037b6137ef1b4d9e4fd4e23a04a58556d4 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 27 Jun 2026 19:35:39 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(Manifest):=20=E6=96=B0=E5=A2=9E=20Comm?= =?UTF-8?q?it=20webview=20=E8=A7=86=E5=9B=BE=E4=B8=8E=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=B9=B6=E5=8D=87=E7=BA=A7=E8=87=B3=200.3.0;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- package.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 91ab1b0..524bcbd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "hyper-git", "displayName": "Hyper Git", "description": "在 VS Code 上完整复刻 IntelliJ IDEA 的 Git 工具窗口与 Commit 提交窗口,并为未来 AI Agent 自主代理预留架构接缝。", - "version": "0.2.0", + "version": "0.3.0", "publisher": "threefish-ai", "license": "MIT", "preview": true, @@ -57,6 +57,12 @@ "id": "hyperGit.changes", "name": "Changes", "visibility": "visible" + }, + { + "id": "hyperGit.commit", + "name": "Commit", + "type": "webview", + "visibility": "visible" } ] }, @@ -74,7 +80,9 @@ { "command": "hyperGit.renameChangelist", "title": "重命名 Changelist", "category": "Hyper Git" }, { "command": "hyperGit.deleteChangelist", "title": "删除 Changelist", "category": "Hyper Git" }, { "command": "hyperGit.moveChangelist", "title": "移至 Changelist…", "category": "Hyper Git" }, - { "command": "hyperGit.openDiff", "title": "打开 Diff", "category": "Hyper Git" } + { "command": "hyperGit.openDiff", "title": "打开 Diff", "category": "Hyper Git" }, + { "command": "hyperGit.commit", "title": "提交", "category": "Hyper Git", "icon": "$(check)" }, + { "command": "hyperGit.commitAndPush", "title": "提交并推送", "category": "Hyper Git", "icon": "$(cloud-upload)" } ], "menus": { "view/title": [ From d5dbe9383cde906ff3f9759bdd1b98610518aa04 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 27 Jun 2026 19:35:39 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs(M2):=20CHANGELOG=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=20M2=20=E4=BA=A4=E4=BB=98=E5=B9=B6=E8=A1=A5=E5=85=85=20vscode.?= =?UTF-8?q?git=20=E8=B7=AF=E5=BE=84=E8=AF=AD=E4=B9=89=20Issue;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .agents/issue.md | 9 +++++++++ CHANGELOG.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/.agents/issue.md b/.agents/issue.md index 90fc2a2..b272e61 100644 --- a/.agents/issue.md +++ b/.agents/issue.md @@ -46,3 +46,12 @@ - **后续防范**:含 test-electron 的扩展,eslint ignores 必须含 `.vscode-test/**`(及 `out/**`、`dist/**`、`*.vsix`);CI 因不缓存该目录可能不暴露,但本地必现——本地与 CI 环境差异需警惕。 - **同类问题影响**:所有跑过 test-electron 的本地环境的 eslint/其他静态分析工具。 +## #6 vscode.git 公开 API add() 须传绝对路径 + +- **表因**:调用 `Repository.add(['README.md'])`(相对路径)无效或误加文件;CommitService 初期也曾困惑路径语义。 +- **根因**:`extensions/git/src/api/api1.ts` 的 `add(paths)` 实现为 `paths.map(p => Uri.file(p))`——`Uri.file()` 要求**绝对路径**;相对路径会被包装成畸形 Uri,内部 `path.relative(root, ...)` 计算错误。`revert`/`clean`/`restore` 同理。 +- **处理方式**:CommitService 始终传 `ChangeItem.uri.fsPath`(绝对)。 +- **后续防范**:消费 vscode.git 公开 API 的路径类方法(add/revert/clean/restore)一律传绝对 fsPath;已加集成测试 `tests/suite/commit-flow.test.js` 守护。 +- **同类问题影响**:所有消费 vscode.git API 做 stage/revert 的扩展;git CLI 本身接受相对路径,但**公开 API 层不接受**,二者语义差异易踩。 + + diff --git a/CHANGELOG.md b/CHANGELOG.md index c72edde..e8fec85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ## [Unreleased] +### Added — M2 Commit 提交窗口(0.3.0) + +- Commit 提交窗口(WebviewView 自绘 IDEA 风格):活动 changelist 文件勾选 + 多行 Commit Message 编辑器 + Amend / Signed-off-by / 跳过 Git hooks 选项 + Commit / Commit and Push 按钮。 +- Conventional Commits 实时校验:`engine/commit/conventional-linter` 纯函数 + webview 指示器(ok/warning/error)+ 内置 `ConventionalCommitCheck` Checkin hook(pipeline 内阻断不合规提交)。 +- `CommitPipeline` 责任链接入提交流程(对齐 IDEA `CheckinHandler`:校验 → stage → hook 链 → commit → 可选 push)。 +- AI 接缝 5 接口 + Null 实现注入 CommitService(`ILlmProvider` / `ICommitMessageProvider` / `IPreCommitInspector` / `IChangelistGrouper` / `IConflictResolver`),M5 替换为真实实现。 +- 最近提交消息复用(`workspaceState` 持久化,webview 一键填入)。 +- 真实 git 提交闭环集成测试(fixture 仓库 + `vscode.git` add/commit + git log 校验)。 + ### Added — M1 Git Adapter + 多 changelist Changes(0.2.0) - Git Adapter:`GitRepositoryService` 封装内置 vscode.git 稳定 `Repository` API(读取 workingTreeChanges/untrackedChanges、状态变更事件、diff/toGitUri 委托)。