Git 集成实战
把 Git 封装成自定义 SCM 提供者是 SCM API 最典型的应用。本文演示如何调用 Git 命令、解析文件状态、监听变更并驱动 SCM 视图。
调用 Git 命令
两种常见方式:child_process.exec 直接调用,或 simple-git 库封装。
child_process 方式
typescript
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
// 在指定目录执行 Git 命令
async function git(repoPath: string, args: string[]): Promise<string> {
const { stdout } = await execFile('git', args, {
cwd: repoPath,
maxBuffer: 1024 * 1024 * 10, // 大仓库需要提升缓冲
encoding: 'utf8'
});
return stdout.trim();
}
// 使用示例
const status = await git('/repo', ['status', '--porcelain']);
const branch = await git('/repo', ['rev-parse', '--abbrev-ref', 'HEAD']);simple-git 方式
typescript
import simpleGit from 'simple-git';
const gitInstance = simpleGit('/repo', { maxConcurrentProcesses: 4 });
async function getStatus() {
const status = await gitInstance.status();
// status.modified / created / deleted / staged 等
return status;
}| 对比 | child_process | simple-git |
|---|---|---|
| 依赖 | 无 | 需安装 simple-git |
| 灵活性 | 任意命令 | 封装常用命令 |
| 类型提示 | 无 | TypeScript 友好 |
读取 .git 目录解析文件状态
git status --porcelain 输出紧凑格式,逐行解析:
typescript
interface FileChange {
uri: vscode.Uri;
status: 'modified' | 'added' | 'deleted' | 'renamed';
staged: boolean;
}
// git status --porcelain 输出示例:
// M src/a.js (已暂存的修改:M 在第一列)
// M src/b.js (未暂存的修改:M 在第二列)
// ?? src/new.js (未跟踪)
// D src/old.js (已删除)
// R old.js -> new.js (重命名)
function parsePorcelain(output: string, repoPath: string): FileChange[] {
const changes: FileChange[] = [];
for (const line of output.split('\n')) {
if (!line.trim()) continue;
const stagedStatus = line[0]; // 第一列:索引状态
const workStatus = line[1]; // 第二列:工作区状态
const filePart = line.slice(3).trim();
// 处理重命名/复制:`R old -> new`
const arrow = filePart.indexOf('->');
const filePath = arrow !== -1 ? filePart.slice(arrow + 2).trim() : filePart;
const uri = vscode.Uri.file(require('path').join(repoPath, filePath));
if (stagedStatus === '??') {
// 未跟踪文件:'??' 整体占两位
changes.push({ uri, status: 'added', staged: false });
continue;
}
let status: FileChange['status'];
if (stagedStatus === 'D' || workStatus === 'D') {
status = 'deleted';
} else if (stagedStatus === 'A' || workStatus === 'A') {
status = 'added';
} else if (stagedStatus === 'R') {
status = 'renamed';
} else {
status = 'modified';
}
changes.push({ uri, status, staged: stagedStatus !== ' ' });
}
return changes;
}状态列解读
| 输出 | 索引列 | 工作区列 | 含义 |
|---|---|---|---|
M file | M | 空格 | 已暂存的修改 |
M file | 空格 | M | 未暂存的修改 |
MM file | M | M | 暂存后又修改 |
?? file | ? | ? | 未跟踪 |
A file | A | 空格 | 已暂存的新增 |
D file | D | 空格 | 已暂存的删除 |
驱动 SCM 视图
把解析结果映射到资源组:
typescript
import * as vscode from 'vscode';
export class GitScmExtension {
private scm: vscode.SourceControl;
private stagedGroup: vscode.SourceControlResourceGroup;
private changesGroup: vscode.SourceControlResourceGroup;
private untrackedGroup: vscode.SourceControlResourceGroup;
private repoPath: string;
constructor(repoPath: string, context: vscode.ExtensionContext) {
this.repoPath = repoPath;
this.scm = vscode.scm.createSourceControl('mygit', 'MyGit', vscode.Uri.file(repoPath));
this.scm.inputBox.placeholder = '提交信息';
this.stagedGroup = this.scm.createResourceGroup('staged', '已暂存');
this.changesGroup = this.scm.createResourceGroup('changes', '更改');
this.untrackedGroup = this.scm.createResourceGroup('untracked', '未跟踪');
this.stagedGroup.hideWhenEmpty = true;
this.untrackedGroup.hideWhenEmpty = true;
// 输入框校验
this.scm.inputBox.validateInput = (value) =>
value.trim() ? undefined : '请输入提交信息';
// 提交
this.scm.acceptInputCommand = {
command: 'mygit.commit',
title: '提交'
};
context.subscriptions.push(this.scm);
this.refresh();
}
private toResourceState(change: FileChange): vscode.SourceControlResourceState {
const colorKey = {
modified: 'gitDecoration.modifiedResourceForeground',
added: 'gitDecoration.addedResourceForeground',
deleted: 'gitDecoration.deletedResourceForeground'
}[change.status];
return {
resourceUri: change.uri,
contextValue: `mygit.${change.status}`,
command: {
command: 'vscode.diff',
title: '比较',
arguments: [
this.getOriginalUri(change), // 原始版本
change.uri, // 当前版本
`${change.uri.fsPath.split(/[\\/]/).pop()}`
]
},
decorations: {
letter: { modified: 'M', added: 'A', deleted: 'D', renamed: 'R' }[change.status],
tooltip: change.status,
strikeThrough: change.status === 'deleted',
color: new vscode.ThemeColor(colorKey)
}
};
}
private async refresh() {
const statusOutput = await git(this.repoPath, ['status', '--porcelain']);
const changes = parsePorcelain(statusOutput, this.repoPath);
// 分发到不同资源组
this.stagedGroup.resourceStates = changes
.filter((c) => c.staged)
.map((c) => this.toResourceState(c));
this.changesGroup.resourceStates = changes
.filter((c) => !c.staged && c.status !== 'added')
.map((c) => this.toResourceState(c));
this.untrackedGroup.resourceStates = changes
.filter((c) => c.status === 'added' && !c.staged)
.map((c) => this.toResourceState(c));
// 计数徽章
this.scm.count = changes.length;
}
}onDidChangeResourceStates 监听状态变更
SCM 视图变化需要及时刷新。监听文件系统与文档事件:
typescript
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
const extension = new GitScmExtension('/repo', context);
// 1. 文档保存后刷新
context.subscriptions.push(
vscode.workspace.onDidSaveTextDocument(() => extension.refresh())
);
// 2. 文件系统事件(新增/删除/改名)
const watcher = vscode.workspace.createFileSystemWatcher('**/*');
context.subscriptions.push(
watcher.onDidCreate(() => extension.refresh()),
watcher.onDidDelete(() => extension.refresh()),
watcher.onDidChange(() => extension.refresh())
);
// 3. 手动刷新命令
context.subscriptions.push(
vscode.commands.registerCommand('mygit.refresh', () => extension.refresh())
);
// 4. 定时刷新(可选,兜底外部变更)
const timer = setInterval(() => extension.refresh(), 30000);
context.subscriptions.push(new vscode.Disposable(() => clearInterval(timer)));
}防抖刷新
Git 命令有成本,高频事件应合并:
typescript
private refreshTimer: NodeJS.Timeout | undefined;
public scheduleRefresh(delay = 300) {
if (this.refreshTimer) clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(() => this.refresh(), delay);
}
// 所有事件入口都走 scheduleRefresh
watcher.onDidCreate(() => extension.scheduleRefresh());statusBarCommands 切换分支/拉取/推送
状态栏命令管理 Git 高频操作:
typescript
private async updateStatusBar() {
const branch = await git(this.repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
const ahead = await git(this.repoPath, ['rev-list', '--count', '@{u}..HEAD']).catch(() => '0');
const behind = await git(this.repoPath, ['rev-list', '--count', 'HEAD..@{u}']).catch(() => '0');
this.scm.description = `分支: ${branch}`;
this.scm.statusBarCommands = [
{
command: 'mygit.switchBranch',
title: `$(git-branch) ${branch}`,
tooltip: '切换分支',
arguments: []
},
{
command: 'mygit.pull',
title: `$(cloud-download) 拉取${behind !== '0' ? ` ${behind}` : ''}`,
tooltip: '拉取更新'
},
{
command: 'mygit.push',
title: `$(cloud-upload) 推送${ahead !== '0' ? ` ${ahead}` : ''}`,
tooltip: '推送提交'
}
];
}命令实现:
typescript
context.subscriptions.push(
// 切换分支
vscode.commands.registerCommand('mygit.switchBranch', async () => {
const branches = (await git(this.repoPath, ['branch', '--format', '%(refname:short)']))
.split('\n').filter(Boolean);
const selected = await vscode.window.showQuickPick(branches, {
placeHolder: '选择分支'
});
if (selected) {
await git(this.repoPath, ['checkout', selected]);
await extension.scheduleRefresh();
}
}),
// 拉取
vscode.commands.registerCommand('mygit.pull', async () => {
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: '拉取中...' },
async () => {
await git(this.repoPath, ['pull']);
await extension.scheduleRefresh(0);
}
);
}),
// 推送
vscode.commands.registerCommand('mygit.push', async () => {
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: '推送中...' },
async () => {
await git(this.repoPath, ['push']);
await extension.scheduleRefresh(0);
}
);
})
);提交命令
typescript
context.subscriptions.push(
vscode.commands.registerCommand('mygit.commit', async () => {
const message = extension.scm.inputBox.value.trim();
if (!message) {
vscode.window.showWarningMessage('提交信息为空');
return;
}
try {
// 暂存所有更改并提交
await git(this.repoPath, ['add', '-A']);
await git(this.repoPath, ['commit', '-m', message]);
extension.scm.inputBox.value = '';
await extension.scheduleRefresh(0);
vscode.window.showInformationMessage(`已提交: ${message}`);
} catch (err) {
vscode.window.showErrorMessage(`提交失败: ${err.message}`);
}
})
);右键菜单动作
json
{
"contributes": {
"menus": {
"scm/resourceState/context": [
{
"command": "mygit.stage",
"when": "scmProvider == mygit && resourceState.contextValue != mygit.staged",
"group": "1_modification",
"title": "暂存"
},
{
"command": "mygit.discard",
"when": "scmProvider == mygit",
"group": "2_actions",
"title": "放弃更改"
}
]
}
}
}typescript
// 暂存单个文件
vscode.commands.registerCommand('mygit.stage', async (state: vscode.SourceControlResourceState) => {
await git(this.repoPath, ['add', state.resourceUri.fsPath]);
await extension.scheduleRefresh(0);
});
// 放弃更改
vscode.commands.registerCommand('mygit.discard', async (state: vscode.SourceControlResourceState) => {
const answer = await vscode.window.showWarningMessage(
`确定放弃 ${state.resourceUri.fsPath} 的更改?`,
{ modal: true },
'放弃'
);
if (answer === '放弃') {
await git(this.repoPath, ['checkout', '--', state.resourceUri.fsPath]);
await extension.scheduleRefresh(0);
}
});Git 集成的核心链路(状态扫描 → 资源组映射 → 命令执行)已经打通。接下来把时间线视图引入,展示提交历史。