singularity-forge/vscode-extension/src/git-integration.ts
2026-05-05 14:46:18 +02:00

131 lines
3.6 KiB
TypeScript

import { execFile } from "node:child_process";
import * as vscode from "vscode";
import type { SfChangeTracker } from "./change-tracker.js";
/**
* Provides git integration for agent changes — commit, branch, and diff.
*/
export class SfGitIntegration implements vscode.Disposable {
private disposables: vscode.Disposable[] = [];
constructor(
private readonly tracker: SfChangeTracker,
private readonly cwd: string,
) {}
/**
* Commit all files modified by the agent with a user-provided message.
*/
async commitAgentChanges(): Promise<void> {
const files = this.tracker.modifiedFiles;
if (files.length === 0) {
vscode.window.showInformationMessage("No agent changes to commit.");
return;
}
const defaultMsg = `feat: agent changes (${files.length} file${files.length !== 1 ? "s" : ""})`;
const message = await vscode.window.showInputBox({
prompt: "Commit message for agent changes",
value: defaultMsg,
placeHolder: "feat: describe the changes",
});
if (!message) return;
try {
// Stage the modified files
await this.git(["add", ...files]);
// Commit
await this.git(["commit", "-m", message]);
// Accept all changes (clear tracking since they're committed)
this.tracker.acceptAll();
vscode.window.showInformationMessage(
`Committed ${files.length} file${files.length !== 1 ? "s" : ""}.`,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Git commit failed: ${msg}`);
}
}
/**
* Create a new branch for agent work and switch to it.
*/
async createAgentBranch(): Promise<void> {
const branchName = await vscode.window.showInputBox({
prompt: "Branch name for agent work",
placeHolder: "feat/agent-changes",
validateInput: (value) => {
if (!value.trim()) return "Branch name is required";
if (/\s/.test(value)) return "Branch name cannot contain spaces";
return null;
},
});
if (!branchName) return;
try {
await this.git(["checkout", "-b", branchName]);
vscode.window.showInformationMessage(
`Created and switched to branch: ${branchName}`,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Failed to create branch: ${msg}`);
}
}
/**
* Show a git diff of all agent-modified files.
*/
async showAgentDiff(): Promise<void> {
const files = this.tracker.modifiedFiles;
if (files.length === 0) {
vscode.window.showInformationMessage("No agent changes to diff.");
return;
}
try {
const diff = await this.git(["diff"]);
if (!diff.trim()) {
// Files may be untracked — show status instead
const status = await this.git(["status", "--short"]);
const channel = vscode.window.createOutputChannel("SF Git Diff");
channel.appendLine("# Agent-modified files (unstaged):");
channel.appendLine(status);
channel.show();
} else {
const channel = vscode.window.createOutputChannel("SF Git Diff");
channel.clear();
channel.appendLine(diff);
channel.show();
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Git diff failed: ${msg}`);
}
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
private git(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
"git",
args,
{ cwd: this.cwd, maxBuffer: 10 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
reject(new Error(stderr.trim() || err.message));
} else {
resolve(stdout);
}
},
);
});
}
}