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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@

## [Unreleased]

### Added — Parity Batch 1(CLI 功能补齐,0.0.2)

> 关键转向:引入 `GitRepositoryService.execGit`(复用 vscode.git 的同一 git 二进制 `api.git.path`),补齐稳定 API 未暴露的操作——修正此前"API 限制延后"的过度自我设限。

- Cherry-pick(Log 右键)、Revert commit(Log 右键)——经 `git cherry-pick` / `git revert`。
- Reset HEAD(soft/mixed/hard/keep,命令面板,hard 二次确认)。
- 分支重命名(Branches 右键 `git branch -m`)、比较分支(`git diff --stat a...b`)。
- Ignore(写 .gitignore)、改写最新提交(amend)。
- **Stash 多栈列表**:用 `git stash list`(CLI)修复——apply/pop/drop 按真实 `stash@{n}` 索引(此前 `log({refNames:['stash']})` 仅返回最新 stash 内部提交、语义错误,已弃用)。

## [0.0.1-rc.1] - 2026-06-27 — 首发候选(Pre-release)

首个公开预发布候选,整合 M0-M4 里程碑交付 + 两轮独立 code review 修复。
Expand Down
33 changes: 25 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "Hyper Git",
"icon": "media/icon.png",
"description": "在 VS Code 上完整复刻 IntelliJ IDEA 的 Git 工具窗口与 Commit 提交窗口,并为未来 AI Agent 自主代理预留架构接缝。",
"version": "0.0.1",
"version": "0.0.2",
"publisher": "threefish-ai",
"license": "MIT",
"preview": true,
Expand Down Expand Up @@ -123,7 +123,14 @@
{ "command": "hyperGit.discardChanges", "title": "丢弃改动", "category": "Hyper Git" },
{ "command": "hyperGit.pull", "title": "Pull", "category": "Hyper Git", "icon": "$(arrow-down)" },
{ "command": "hyperGit.push", "title": "Push", "category": "Hyper Git", "icon": "$(arrow-up)" },
{ "command": "hyperGit.fetch", "title": "Fetch", "category": "Hyper Git" }
{ "command": "hyperGit.fetch", "title": "Fetch", "category": "Hyper Git" },
{ "command": "hyperGit.cherryPick", "title": "Cherry-Pick", "category": "Hyper Git" },
{ "command": "hyperGit.revertCommit", "title": "Revert Commit", "category": "Hyper Git" },
{ "command": "hyperGit.resetHead", "title": "Reset HEAD…", "category": "Hyper Git" },
{ "command": "hyperGit.branchRename", "title": "重命名分支", "category": "Hyper Git" },
{ "command": "hyperGit.ignorePath", "title": "添加到 .gitignore", "category": "Hyper Git" },
{ "command": "hyperGit.compareBranches", "title": "比较分支", "category": "Hyper Git" },
{ "command": "hyperGit.rewordCommit", "title": "改写最新提交信息", "category": "Hyper Git" }
],
"menus": {
"view/title": [
Expand All @@ -137,10 +144,7 @@
{ "command": "hyperGit.branchCreate", "when": "view == hyperGit.branches", "group": "navigation" },
{ "command": "hyperGit.pull", "when": "view == hyperGit.branches", "group": "navigation" },
{ "command": "hyperGit.push", "when": "view == hyperGit.branches", "group": "navigation" },
{ "command": "hyperGit.stashCreate", "when": "view == hyperGit.stash", "group": "navigation" },
{ "command": "hyperGit.stashApply", "when": "view == hyperGit.stash" },
{ "command": "hyperGit.stashPop", "when": "view == hyperGit.stash" },
{ "command": "hyperGit.stashDrop", "when": "view == hyperGit.stash" }
{ "command": "hyperGit.stashCreate", "when": "view == hyperGit.stash", "group": "navigation" }
],
"view/item/context": [
{ "command": "hyperGit.setActiveChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.changelist", "group": "1_changelist@1" },
Expand All @@ -154,7 +158,15 @@
{ "command": "hyperGit.branchCheckout", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@1" },
{ "command": "hyperGit.branchDelete", "when": "view == hyperGit.branches && viewItem == hyperGit.branch", "group": "1_branch@2" },
{ "command": "hyperGit.mergeBranch", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@3" },
{ "command": "hyperGit.rebaseBranch", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@4" }
{ "command": "hyperGit.rebaseBranch", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@4" },
{ "command": "hyperGit.branchRename", "when": "view == hyperGit.branches && viewItem == hyperGit.branch", "group": "1_branch@5" },
{ "command": "hyperGit.compareBranches", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@6" },
{ "command": "hyperGit.cherryPick", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@1" },
{ "command": "hyperGit.revertCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@2" },
{ "command": "hyperGit.ignorePath", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@4" },
{ "command": "hyperGit.stashApply", "when": "view == hyperGit.stash && viewItem == hyperGit.stash", "group": "1_stash@1" },
{ "command": "hyperGit.stashPop", "when": "view == hyperGit.stash && viewItem == hyperGit.stash", "group": "1_stash@2" },
{ "command": "hyperGit.stashDrop", "when": "view == hyperGit.stash && viewItem == hyperGit.stash", "group": "1_stash@3" }
],
"commandPalette": [
{ "command": "hyperGit.openDiff", "when": "false" },
Expand All @@ -171,7 +183,12 @@
{ "command": "hyperGit.stashApply", "when": "false" },
{ "command": "hyperGit.stashPop", "when": "false" },
{ "command": "hyperGit.stashDrop", "when": "false" },
{ "command": "hyperGit.discardChanges", "when": "false" }
{ "command": "hyperGit.discardChanges", "when": "false" },
{ "command": "hyperGit.cherryPick", "when": "false" },
{ "command": "hyperGit.revertCommit", "when": "false" },
{ "command": "hyperGit.branchRename", "when": "false" },
{ "command": "hyperGit.compareBranches", "when": "false" },
{ "command": "hyperGit.ignorePath", "when": "false" }
]
},
"configuration": {
Expand Down
201 changes: 201 additions & 0 deletions src/adapter/git-cli-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import type { BranchNode, BranchesTreeProvider } from './tree/branches-tree';
import type { ChangeItem, GitRepositoryService } from './git-repository-service';
import type { LogNode } from './tree/log-tree';

const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));

/**
* 注册经 git CLI 补齐的操作(M5' batch 1):cherry-pick / revert / reset / branch rename /
* ignore / compare branches / reword。均经 `service.execGit`(复用 vscode.git 的同一 git 二进制)。
*/
export function registerGitCliCommands(service: GitRepositoryService, branchesTree: BranchesTreeProvider): vscode.Disposable[] {
const subs: vscode.Disposable[] = [];

subs.push(
vscode.commands.registerCommand('hyperGit.cherryPick', async (node?: LogNode) => {
const repo = service.repo;
if (!repo) {
return;
}
const hash = node?.kind === 'commit' ? node.commit.hash : await pickCommitHash(service);
if (!hash) {
return;
}
try {
await service.execGit(['cherry-pick', hash]);
void vscode.window.showInformationMessage(`Cherry-pick ${hash.slice(0, 7)} 完成`);
} catch (e) {
void vscode.window.showErrorMessage(`Cherry-pick 失败:${errMsg(e)}`);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.revertCommit', async (node?: LogNode) => {
const repo = service.repo;
if (!repo) {
return;
}
const hash = node?.kind === 'commit' ? node.commit.hash : await pickCommitHash(service);
if (!hash) {
return;
}
try {
await service.execGit(['revert', '--no-edit', hash]);
void vscode.window.showInformationMessage(`Revert ${hash.slice(0, 7)} 完成`);
} catch (e) {
void vscode.window.showErrorMessage(`Revert 失败:${errMsg(e)}`);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.resetHead', async () => {
const repo = service.repo;
if (!repo) {
return;
}
const items = [
{ label: 'soft', description: '保留工作区与暂存区改动' },
{ label: 'mixed', description: '保留工作区改动,取消暂存(默认)' },
{ label: 'hard', description: '⚠ 丢弃所有改动(不可撤销)' },
{ label: 'keep', description: '保留工作区已修改文件' },
];
const pick = await vscode.window.showQuickPick(items, { placeHolder: '选择 reset 模式(作用于 HEAD)' });
if (!pick) {
return;
}
if (pick.label === 'hard') {
const ok = await vscode.window.showWarningMessage('hard reset 将丢弃所有改动,确认?', { modal: true }, '确认 hard reset');
if (!ok) {
return;
}
}
try {
await service.execGit(['reset', `--${pick.label}`, 'HEAD~0']);
branchesTree.refresh();
void vscode.window.showInformationMessage(`Reset (--${pick.label}) 完成`);
} catch (e) {
void vscode.window.showErrorMessage(`Reset 失败:${errMsg(e)}`);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.branchRename', async (node?: BranchNode) => {
const repo = service.repo;
if (!repo || node?.kind !== 'branch') {
return;
}
const oldName = node.ref.name ?? '';
const newName = await vscode.window.showInputBox({ prompt: `重命名分支「${oldName}」`, value: oldName });
if (!newName || !newName.trim() || newName === oldName) {
return;
}
try {
await service.execGit(['branch', '-m', oldName, newName.trim()]);
branchesTree.refresh();
} catch (e) {
void vscode.window.showErrorMessage(`重命名失败:${errMsg(e)}`);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.ignorePath', async (change?: ChangeItem) => {
const repo = service.repo;
if (!repo) {
return;
}
const rel = change?.relativePath ?? (await pickRelativePath(service));
if (!rel) {
return;
}
const ignoreFile = path.join(repo.rootUri.fsPath, '.gitignore');
try {
await new Promise<void>((resolve, reject) => {
fs.appendFile(ignoreFile, `${rel}\n`, (err) => (err ? reject(err) : resolve()));
});
void vscode.window.showInformationMessage(`已添加到 .gitignore:${rel}`);
} catch (e) {
void vscode.window.showErrorMessage(`Ignore 失败:${errMsg(e)}`);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.compareBranches', async (node?: BranchNode) => {
const repo = service.repo;
if (!repo) {
return;
}
const refs = repo.state.refs.filter((r) => r.name && (r.type === 0 || r.type === 1));
const base = node?.kind === 'branch' ? node.ref.name : await vscode.window.showQuickPick(refs.map((r) => r.name!), { placeHolder: '选择 base 分支' });
if (!base) {
return;
}
const target = await vscode.window.showQuickPick(
refs.filter((r) => r.name !== base).map((r) => r.name!),
{ placeHolder: `比较 ${base} 与...` },
);
if (!target) {
return;
}
try {
const out = await service.execGit(['diff', '--stat', `${base}...${target}`]);
const doc = await vscode.workspace.openTextDocument({ content: `$ git diff --stat ${base}...${target}\n\n${out}`, language: 'plaintext' });
await vscode.window.showTextDocument(doc, { preview: true });
} catch (e) {
void vscode.window.showErrorMessage(`比较失败:${errMsg(e)}`);
}
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.rewordCommit', async () => {
const repo = service.repo;
if (!repo) {
return;
}
const message = await vscode.window.showInputBox({ prompt: '改写最新提交信息(amend)', value: (await repo.log({ maxEntries: 1 }))[0]?.message ?? '' });
if (!message || !message.trim()) {
return;
}
try {
await repo.commit(message.trim(), { amend: true });
void vscode.window.showInformationMessage('已改写最新提交');
} catch (e) {
void vscode.window.showErrorMessage(`改写失败:${errMsg(e)}`);
}
}),
);

return subs;
}

async function pickCommitHash(service: GitRepositoryService): Promise<string | undefined> {
const repo = service.repo;
if (!repo) {
return undefined;
}
const commits = await repo.log({ maxEntries: 30 });
const items = commits.map((c) => ({
label: (c.message.split('\n', 1)[0] ?? c.hash).slice(0, 50),
description: `${c.authorName ?? ''} · ${c.hash.slice(0, 7)}`,
hash: c.hash,
}));
const pick = await vscode.window.showQuickPick(items, { placeHolder: '选择 commit' });
return pick?.hash;
}

async function pickRelativePath(service: GitRepositoryService): Promise<string | undefined> {
const changes = service.getChanges();
if (changes.length === 0) {
return undefined;
}
const pick = await vscode.window.showQuickPick(changes.map((c) => ({ label: c.relativePath })), { placeHolder: '选择要忽略的文件' });
return pick?.label;
}
22 changes: 22 additions & 0 deletions src/adapter/git-repository-service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { execFile } from 'child_process';
import * as path from 'path';
import * as vscode from 'vscode';
import type { API, Change, Repository } from '../types/git';
Expand Down Expand Up @@ -100,6 +101,27 @@ export class GitRepositoryService implements vscode.Disposable {
return this.api.toGitUri(uri, ref);
}

/**
* 受控 git CLI 通道:复用 vscode.git 的同一 git 二进制(`api.git.path`),补齐稳定 API 未暴露的操作
* (cherry-pick / revert / reset / branch rename / stash list / compare 等)。仓库根为工作目录。
* 仅作为 API 缺口的补充,不重造 vscode.git 已覆盖的能力。
*/
async execGit(args: string[]): Promise<string> {
const repo = this._repo;
if (!repo) {
throw new Error('未找到 Git 仓库');
}
return new Promise((resolve, reject) => {
execFile(this.api.git.path, args, { cwd: repo.rootUri.fsPath, maxBuffer: 20 * 1024 * 1024, encoding: 'utf8' }, (err, stdout) => {
if (err) {
reject(err);
} else {
resolve(stdout);
}
});
});
}

dispose(): void {
this.repoSub?.dispose();
this.disposables.forEach((d) => d.dispose());
Expand Down
22 changes: 12 additions & 10 deletions src/adapter/stash-commands.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import * as vscode from 'vscode';
import type { GitRepositoryService } from './git-repository-service';
import type { StashTreeProvider } from './tree/stash-tree';
import type { StashNode, StashTreeProvider } from './tree/stash-tree';

/**
* 注册 Stash 相关命令(M4)。
*
* 经 vscode.git 稳定 API:createStash / applyStash / popStash / dropStash。
* 因 API 无 stash 列表枚举(见 StashTreeProvider),apply/pop/drop 作用于 stash@{0}(最新)。
* 「Shelve Changes」以 stash 近似(MVP,见工程方案 §4 P2)。
* stash 列表由 `StashTreeProvider`(execGit `git stash list`)枚举,apply/pop/drop 按 stash@{n} 真实索引。
*/
export function registerStashCommands(service: GitRepositoryService, stashTree: StashTreeProvider): vscode.Disposable[] {
const subs: vscode.Disposable[] = [];
Expand All @@ -30,13 +29,14 @@ export function registerStashCommands(service: GitRepositoryService, stashTree:
);

subs.push(
vscode.commands.registerCommand('hyperGit.stashApply', async () => {
vscode.commands.registerCommand('hyperGit.stashApply', async (node?: StashNode) => {
const repo = service.repo;
if (!repo) {
return;
}
const index = node?.kind === 'stash' ? node.index : 0;
try {
await repo.applyStash(0);
await repo.applyStash(index);
stashTree.refresh();
} catch (e) {
void vscode.window.showErrorMessage(`Stash 应用失败:${errMsg(e)}`);
Expand All @@ -45,13 +45,14 @@ export function registerStashCommands(service: GitRepositoryService, stashTree:
);

subs.push(
vscode.commands.registerCommand('hyperGit.stashPop', async () => {
vscode.commands.registerCommand('hyperGit.stashPop', async (node?: StashNode) => {
const repo = service.repo;
if (!repo) {
return;
}
const index = node?.kind === 'stash' ? node.index : 0;
try {
await repo.popStash(0);
await repo.popStash(index);
stashTree.refresh();
} catch (e) {
void vscode.window.showErrorMessage(`Stash pop 失败:${errMsg(e)}`);
Expand All @@ -60,15 +61,16 @@ export function registerStashCommands(service: GitRepositoryService, stashTree:
);

subs.push(
vscode.commands.registerCommand('hyperGit.stashDrop', async () => {
vscode.commands.registerCommand('hyperGit.stashDrop', async (node?: StashNode) => {
const repo = service.repo;
if (!repo) {
return;
}
const choice = await vscode.window.showWarningMessage('删除最新的 stash(stash@{0})?', { modal: true }, '删除');
const index = node?.kind === 'stash' ? node.index : 0;
const choice = await vscode.window.showWarningMessage(`删除 stash@{${index}}?`, { modal: true }, '删除');
if (choice === '删除') {
try {
await repo.dropStash(0);
await repo.dropStash(index);
stashTree.refresh();
} catch (e) {
void vscode.window.showErrorMessage(`Stash 删除失败:${errMsg(e)}`);
Expand Down
Loading
Loading