Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@

## [Unreleased]

### Fixed — M0/M1/M2 审查修复(0.3.1)

经 3 路独立 code review(正确性 / 架构 / 完整性)交叉复核后修复:

- **GitRepositoryService**:仓库切换时 `onDidChange` 订阅累积泄漏 → 改用单 `repoSub`,切换/卸载时 dispose。
- **pickRepository**:`startsWith` 误匹配(无路径边界)→ 改用 `api.getRepository(folder.uri)` 精确匹配。
- **getChanges**:缺失 `indexChanges`(已暂存文件不可见)→ 合并 index/working/untracked 按相对路径去重(index 优先)。
- **commit 语义**:未勾选的已暂存文件先 `restore --staged`,让勾选集成为提交权威范围(对齐 IDEA「提交该集合」)。
- **push 失败**:commit 成功后 push 失败误报「提交失败」→ 返回 `ok:true` + `warning`。
- **extension.ts**:三个 `onDidChange` 订阅入 `subscriptions`(修复卸载泄漏)。
- **refresh**:`await repo.status()` 后再刷新(避免陈旧数据)。
- **conventional-linter**:Windows `\r\n` 行尾 + 中文/Unicode scope 支持。
- **commit-webview**:`onDidReceiveMessage` 绑定 `view.onDidDispose`(修复重载泄漏);nonce 改用 `crypto.randomBytes`;选中态 `setState` 持久化。
- **changes-tree**:tooltip 显示状态全称(Modified 而非 M);清理 `CommitFileItem` 冗余 `status/statusName` 字段。
- **测试补齐**:`ConventionalCommitCheck`、`CommitService.executeCommit`(mock Repository,覆盖 CC 阻断/无文件/amend 透传/unstage/push 警告)、`git-status-map` 全量、`amend` 真实集成。

### Added — M2 Commit 提交窗口(0.3.0)

- Commit 提交窗口(WebviewView 自绘 IDEA 风格):活动 changelist 文件勾选 + 多行 Commit Message 编辑器 + Amend / Signed-off-by / 跳过 Git hooks 选项 + Commit / Commit and Push 按钮。
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "hyper-git",
"displayName": "Hyper Git",
"description": "在 VS Code 上完整复刻 IntelliJ IDEA 的 Git 工具窗口与 Commit 提交窗口,并为未来 AI Agent 自主代理预留架构接缝。",
"version": "0.3.0",
"version": "0.3.1",
"publisher": "threefish-ai",
"license": "MIT",
"preview": true,
Expand Down
7 changes: 4 additions & 3 deletions src/adapter/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export function registerChangesCommands(
const subs: vscode.Disposable[] = [];

subs.push(
vscode.commands.registerCommand('hyperGit.refresh', () => {
void service.repo?.status();
vscode.commands.registerCommand('hyperGit.refresh', async () => {
await service.repo?.status();
tree.refresh();
}),
);
Expand Down Expand Up @@ -87,7 +87,8 @@ export function registerChangesCommands(
if (!repo) {
return;
}
const left = service.toGitUri(change.uri, 'HEAD');
// 空仓库(无 HEAD)时用 originalUri 兜底,避免 git scheme 解析失败
const left = repo.state.HEAD ? service.toGitUri(change.uri, 'HEAD') : change.originalUri;
const right = change.uri;
const title = `${path.basename(change.relativePath)} (HEAD ↔ Working)`;
await vscode.commands.executeCommand('vscode.diff', left, right, title);
Expand Down
22 changes: 17 additions & 5 deletions src/adapter/commit/commit-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface CommitRequest {
export interface CommitOutcome {
readonly ok: boolean;
readonly error?: string;
/** 提交成功但后续操作(如 push)失败时的提示。 */
readonly warning?: string;
}

/** Commit 流水线依赖的 AI 接缝集合(Null 实现注入,M5 替换为真实实现)。 */
Expand Down Expand Up @@ -118,25 +120,35 @@ export class CommitService implements vscode.Disposable {

// 解析选中文件的绝对路径
const changes = this.service.getChanges();
const checkedSet = new Set(req.selectedPaths);
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 });
// Checkin hook 责任链(传绝对路径,供未来 AI hook 读取文件内容)
const hookResult = await this.pipeline.run({ message, filePaths: absPaths });
if (hookResult === CheckinResult.Cancel) {
return { ok: false, error: '提交被检查拦截(Checkin hook)' };
}

try {
// 让勾选集成为提交的权威范围:未勾选的已暂存文件先 unstage(对齐 IDEA「提交该集合」语义)
const toUnstage = changes.filter((c) => c.staged && !checkedSet.has(c.relativePath)).map((c) => c.uri.fsPath);
if (toUnstage.length > 0) {
await repo.restore(toUnstage, { staged: true });
}
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();
if (req.push) {
try {
await repo.push();
} catch (e) {
return { ok: true, warning: `提交已成功,但推送失败:${this.normalizeError(e)}` };
}
}
return { ok: true };
} catch (e) {
return { ok: false, error: this.normalizeError(e) };
Expand Down
51 changes: 33 additions & 18 deletions src/adapter/git-repository-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as path from 'path';
import * as vscode from 'vscode';
import type { API, Repository } from '../types/git';
import type { API, Change, Repository } from '../types/git';
import { FileStatus } from '../engine/model';
import { mapGitStatus } from './git-status-map';

Expand All @@ -24,6 +24,7 @@ export class GitRepositoryService implements vscode.Disposable {
private readonly _onDidChange = new vscode.EventEmitter<void>();
readonly onDidChange: vscode.Event<void> = this._onDidChange.event;
private readonly disposables: vscode.Disposable[] = [];
private repoSub?: vscode.Disposable;

constructor(private readonly api: API) {
this.disposables.push(api.onDidOpenRepository(() => this.pickRepository()));
Expand All @@ -39,46 +40,59 @@ export class GitRepositoryService implements vscode.Disposable {
return this._repo?.rootUri.fsPath ?? null;
}

/** 选取活跃仓库:优先匹配工作区根,否则首个。 */
/** 选取活跃仓库:优先匹配工作区根(用 API.getRepository,路径段精确匹配),否则首个。 */
private pickRepository(): void {
const folders = vscode.workspace.workspaceFolders;
let repo: Repository | null = null;
if (folders && folders.length > 0) {
const wsRoot = folders[0].uri.fsPath;
repo = this.api.repositories.find((r) => wsRoot.startsWith(r.rootUri.fsPath)) ?? null;
const folder = vscode.workspace.workspaceFolders?.[0];
if (folder) {
repo = this.api.getRepository(folder.uri) ?? null;
}
if (!repo) {
repo = this.api.repositories[0] ?? null;
}
const changed = repo !== this._repo;
if (changed) {
if (repo !== this._repo) {
this.repoSub?.dispose();
this.repoSub = undefined;
this._repo = repo;
if (repo) {
this.disposables.push(repo.state.onDidChange(() => this._onDidChange.fire()));
this.repoSub = repo.state.onDidChange(() => this._onDidChange.fire());
}
this._onDidChange.fire();
}
this._onDidChange.fire();
}

/** 读取本地变更(工作区 + 未跟踪),映射为 ChangeItem。 */
/** 读取本地变更(已暂存 + 工作区 + 未跟踪,按相对路径去重,index 优先),映射为 ChangeItem。 */
getChanges(): ChangeItem[] {
const repo = this._repo;
if (!repo) {
return [];
}
const root = repo.rootUri.fsPath;
const items: ChangeItem[] = [];
const fromChange = (uri: vscode.Uri, originalUri: vscode.Uri, renameUri: vscode.Uri | undefined, status: number, staged: boolean): ChangeItem => {
const rel = path.relative(root, uri.fsPath).split(path.sep).join('/');
return { relativePath: rel, uri, originalUri, renameUri, status: mapGitStatus(status), staged };
const map = new Map<string, ChangeItem>();
const add = (c: Change, staged: boolean): void => {
const rel = path.relative(root, c.uri.fsPath).split(path.sep).join('/');
if (map.has(rel)) {
return;
}
map.set(rel, {
relativePath: rel,
uri: c.uri,
originalUri: c.originalUri,
renameUri: c.renameUri ?? undefined,
status: mapGitStatus(c.status),
staged,
});
};
for (const c of repo.state.indexChanges) {
add(c, true);
}
for (const c of repo.state.workingTreeChanges) {
items.push(fromChange(c.uri, c.originalUri, c.renameUri ?? undefined, c.status, false));
add(c, false);
}
for (const c of repo.state.untrackedChanges) {
items.push(fromChange(c.uri, c.originalUri, c.renameUri ?? undefined, c.status, false));
add(c, false);
}
return items;
return [...map.values()];
}

/** 构造任意 ref 版本的资源 Uri(diff 原始端,复用 vscode.git 的 git scheme)。 */
Expand All @@ -87,6 +101,7 @@ export class GitRepositoryService implements vscode.Disposable {
}

dispose(): void {
this.repoSub?.dispose();
this.disposables.forEach((d) => d.dispose());
this._onDidChange.dispose();
}
Expand Down
3 changes: 2 additions & 1 deletion src/adapter/tree/changes-tree.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from 'path';
import * as vscode from 'vscode';
import { fileStatusLabel } from '../../engine/model';
import { getDecoration } from '../../engine/scm-mapping/status-decoration';
import type { ChangelistRegistry } from '../changelist-registry';
import type { ChangeItem, GitRepositoryService } from '../git-repository-service';
Expand Down Expand Up @@ -87,7 +88,7 @@ export class ChangesTreeProvider implements vscode.TreeDataProvider<ChangesNode>
treeItem.id = `file:${change.relativePath}`;
treeItem.resourceUri = change.uri;
treeItem.description = `${decoration.letter}${dir && dir !== '.' ? ' · ' + dir : ''}`;
treeItem.tooltip = `${change.relativePath}\n状态:${change.status}${change.staged ? '(已暂存)' : ''}`;
treeItem.tooltip = `${change.relativePath}\n状态:${fileStatusLabel(change.status)}${change.staged ? '(已暂存)' : ''}`;
treeItem.iconPath = new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor(decoration.themeColor));
treeItem.command = { command: 'hyperGit.openDiff', title: '打开 Diff', arguments: [change] };
return treeItem;
Expand Down
22 changes: 10 additions & 12 deletions src/adapter/webview/commit-webview.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as crypto from 'crypto';
import * as path from 'path';
import * as vscode from 'vscode';
import { getDecoration } from '../../engine/scm-mapping/status-decoration';
Expand Down Expand Up @@ -28,7 +29,8 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider {
this.view = view;
view.webview.options = { enableScripts: true, localResourceRoots: [] };
view.webview.html = this.renderHtml();
view.webview.onDidReceiveMessage((msg) => this.onMessage(msg as WebviewToHostMessage));
const msgSub = view.webview.onDidReceiveMessage((msg) => this.onMessage(msg as WebviewToHostMessage));
view.onDidDispose(() => msgSub.dispose());
this.pushState();
}

Expand Down Expand Up @@ -81,8 +83,6 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider {
path: c.relativePath,
label: path.basename(c.relativePath),
dir: path.dirname(c.relativePath),
status: decoration.letter,
statusName: c.status,
themeColor: decoration.themeColor,
};
}
Expand Down Expand Up @@ -161,7 +161,9 @@ button:disabled { opacity: 0.5; cursor: default; }

<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
const checked = new Set();
const persisted = vscode.getState();
const checked = new Set(persisted && persisted.checked ? persisted.checked : []);
function saveChecked() { vscode.setState({ checked: Array.from(checked) }); }
let conventionalEnabled = true;
let templateApplied = false;
const filesEl = document.getElementById('files');
Expand Down Expand Up @@ -214,7 +216,7 @@ function renderFiles(files) {
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = checked.has(f.path);
cb.addEventListener('change', function () { if (cb.checked) checked.add(f.path); else checked.delete(f.path); });
cb.addEventListener('change', function () { if (cb.checked) checked.add(f.path); else checked.delete(f.path); saveChecked(); });
const dot = document.createElement('span');
dot.className = 'dot';
dot.style.color = 'var(--vscode-' + f.themeColor.replace(/\\./g, '-') + ')';
Expand All @@ -230,6 +232,7 @@ function renderFiles(files) {
filesEl.appendChild(row);
});
Array.from(checked).forEach(function (p) { if (!present.has(p)) checked.delete(p); });
saveChecked();
}

function renderRecent(messages) {
Expand Down Expand Up @@ -285,7 +288,7 @@ window.addEventListener('message', function (e) {
} else if (m.type === 'commitResult') {
setBusy(false);
if (m.payload.ok) {
toast('提交成功', false);
toast(m.payload.warning || '提交成功', Boolean(m.payload.warning));
msgEl.value = '';
amendEl.checked = false; signoffEl.checked = false; skipHooksEl.checked = false;
vscode.postMessage({ type: 'messageChanged', payload: { message: '' } });
Expand All @@ -303,10 +306,5 @@ vscode.postMessage({ type: 'requestState' });
}

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;
return crypto.randomBytes(16).toString('base64');
}
5 changes: 3 additions & 2 deletions src/engine/commit/conventional-linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export interface ConventionalValidation {
}

const ALLOWED_TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'];
const SUBJECT_RE = new RegExp(`^(${ALLOWED_TYPES.join('|')})(\\([\\w.\\-/]+\\))?(!)?: .+`);
// scope 允许任意非括号非空白字符(含中文/Unicode);Windows 行尾用 \r?\n 切分。
const SUBJECT_RE = new RegExp(`^(${ALLOWED_TYPES.join('|')})(\\([^()\\s]+\\))?(!)?: .+`);
const SUBJECT_MAX_LENGTH = 72;

export const ALLOWED_COMMIT_TYPES = ALLOWED_TYPES;
Expand All @@ -31,7 +32,7 @@ export function validateConventional(message: string): ConventionalValidation {
if (!trimmed) {
return { severity: 'error', reason: '提交信息不能为空' };
}
const subject = message.split('\n', 1)[0] ?? '';
const subject = message.split(/\r?\n/, 1)[0] ?? '';
if (!subject.trim()) {
return { severity: 'error', reason: '主题行(首行)不能为空' };
}
Expand Down
16 changes: 16 additions & 0 deletions src/engine/model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ export enum FileStatus {
Ignored = 'I',
}

const FILE_STATUS_LABELS: Record<FileStatus, string> = {
[FileStatus.Modified]: 'Modified',
[FileStatus.Added]: 'Added',
[FileStatus.Deleted]: 'Deleted',
[FileStatus.Untracked]: 'Untracked',
[FileStatus.Renamed]: 'Renamed',
[FileStatus.Copied]: 'Copied',
[FileStatus.Conflict]: 'Conflict',
[FileStatus.Ignored]: 'Ignored',
};

/** 文件状态的可读名(用于 tooltip 等展示)。 */
export function fileStatusLabel(status: FileStatus): string {
return FILE_STATUS_LABELS[status] ?? 'Unknown';
}

/** 单个文件的变更。uri 为仓库相对路径;rename/copy 时 oldUri 为源路径。 */
export interface FileChange {
readonly uri: string;
Expand Down
8 changes: 5 additions & 3 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
tree.refresh();
commitView.refresh();
};
service.onDidChange(refreshAll);
registry.onDidChange(refreshAll);
commit.onDidChange(refreshAll);
context.subscriptions.push(
service.onDidChange(refreshAll),
registry.onDidChange(refreshAll),
commit.onDidChange(refreshAll),
);
}

export function deactivate(): void {
Expand Down
2 changes: 0 additions & 2 deletions src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ 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-...)
}

Expand Down
19 changes: 19 additions & 0 deletions tests/suite/commit-flow.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const assert = require('assert');
const vscode = require('vscode');
const fs = require('fs');
const path = require('path');
const cp = require('child_process');

Expand Down Expand Up @@ -50,4 +51,22 @@ suite('Commit 流程(真实 git 操作)', function () {
const status = cp.execFileSync('git', ['status', '--porcelain'], { cwd: root }).toString().trim();
assert.strictEqual(status, '', '提交后工作区应为空');
});

test('amend 改写 HEAD 提交信息且不新增 commit', async function () {
const gitExt = vscode.extensions.getExtension('vscode.git');
const api = gitExt.exports.getAPI(1);
const repo = api.repositories[0];
assert.ok(repo, '仓库未就绪');
const root = repo.rootUri.fsPath;

const before = cp.execFileSync('git', ['rev-list', '--count', 'HEAD'], { cwd: root }).toString().trim();
fs.writeFileSync(path.join(root, 'amend.txt'), 'amend\n');
await repo.add([path.join(root, 'amend.txt')]);
await repo.commit('feat(test): 改写后的提交', { amend: true });

const after = cp.execFileSync('git', ['rev-list', '--count', 'HEAD'], { cwd: root }).toString().trim();
const subject = cp.execFileSync('git', ['log', '-1', '--pretty=%s'], { cwd: root }).toString().trim();
assert.strictEqual(after, before, 'amend 不应新增 commit 数');
assert.strictEqual(subject, 'feat(test): 改写后的提交');
});
});
Loading
Loading