feat: Redesign git changes to split stage/unstaged files. (#1359)

* feat: Redesign git changes to split stage/unstaged files.

Signed-off-by: Paolo Insogna <paolo@cowtech.it>

* fixup

Signed-off-by: Paolo Insogna <paolo@cowtech.it>

* fixup

Signed-off-by: Paolo Insogna <paolo@cowtech.it>

* refactor: streamline git changes panel

* fix: label staged and working diff tabs

* fix: isolate staged and working diff files

* fix: scope staged and working diff updates

* fix: scope git row revert to working changes

---------

Signed-off-by: Paolo Insogna <paolo@cowtech.it>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Paolo Insogna
2026-05-24 00:49:38 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 9af0de0056
commit e16097b05d
42 changed files with 2987 additions and 923 deletions
+164 -20
View File
@@ -396,6 +396,10 @@ function mapStatus(status: Status): string {
return statusMap[status] || ' ';
}
function getRepositoryRelativePath(repo: Repository, uri: vscode.Uri): string {
return path.relative(repo.rootUri.fsPath, uri.fsPath).replace(/\\/g, '/');
}
/**
* Get git status for a directory
*/
@@ -417,7 +421,7 @@ export async function getGitStatus(directory: string, options?: GitStatusOptions
// Process index changes (staged)
for (const change of state.indexChanges) {
const relativePath = vscode.workspace.asRelativePath(change.uri, false);
const relativePath = getRepositoryRelativePath(repo, change.uri);
files.push({
path: relativePath,
index: mapStatus(change.status),
@@ -427,7 +431,7 @@ export async function getGitStatus(directory: string, options?: GitStatusOptions
// Process working tree changes (unstaged)
for (const change of state.workingTreeChanges) {
const relativePath = vscode.workspace.asRelativePath(change.uri, false);
const relativePath = getRepositoryRelativePath(repo, change.uri);
const existing = files.find(f => f.path === relativePath);
if (existing) {
existing.working_dir = mapStatus(change.status);
@@ -2085,10 +2089,15 @@ export async function getGitFileDiff(
}
}
// Read the current file content
const fileUri = vscode.Uri.file(path.join(directory, filePath));
const modifiedBytes = await vscode.workspace.fs.readFile(fileUri);
const modified = Buffer.from(modifiedBytes).toString('utf8');
let modified: string;
if (staged) {
const stagedResult = await execGit(['show', `:${filePath}`], directory);
modified = stagedResult.exitCode === 0 ? stagedResult.stdout : '';
} else {
const fileUri = vscode.Uri.file(path.join(directory, filePath));
const modifiedBytes = await vscode.workspace.fs.readFile(fileUri);
modified = Buffer.from(modifiedBytes).toString('utf8');
}
return { original, modified, path: filePath };
} catch (error) {
@@ -2103,20 +2112,101 @@ export async function getGitFileDiff(
/**
* Revert a file to its last committed state
*/
export async function revertGitFile(directory: string, filePath: string): Promise<void> {
const repo = await getRepository(directory);
if (repo) {
try {
await repo.revert([filePath]);
return;
} catch (error) {
console.error('[GitService] Failed to revert via API:', error);
export async function revertGitFile(
directory: string,
filePath: string,
options: { scope?: 'all' | 'working' } = {},
): Promise<void> {
const scope = options.scope === 'working' ? 'working' : 'all';
const tracked = await execGit(['ls-files', '--error-unmatch', '--', filePath], directory);
if (tracked.exitCode !== 0) {
const clean = await execGit(['clean', '-f', '-d', '--', filePath], directory);
if (clean.exitCode !== 0) {
const root = path.resolve(directory);
const target = path.resolve(directory, filePath);
if (target !== root && !target.startsWith(root + path.sep)) {
throw new Error(`Path is outside repository: ${filePath}`);
}
await fs.promises.rm(target, { recursive: true, force: true });
}
return;
}
if (scope === 'all') {
const unstage = await execGit(['restore', '--staged', '--', filePath], directory);
if (unstage.exitCode !== 0) {
await execGit(['reset', 'HEAD', '--', filePath], directory);
}
}
// Fallback to raw git
await execGit(['checkout', '--', filePath], directory);
const restore = await execGit(['restore', '--', filePath], directory);
if (restore.exitCode === 0) {
return;
}
const fallback = await execGit(['checkout', '--', filePath], directory);
if (fallback.exitCode !== 0) {
throw new Error(fallback.stderr || restore.stderr || 'Failed to revert git file');
}
}
export async function stageGitFile(directory: string, filePath: string): Promise<void> {
await stageGitFiles(directory, [filePath]);
}
export async function stageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
if (paths.length === 0) {
throw new Error('path is required');
}
const result = await execGit(['add', '--', ...paths], directory);
if (result.exitCode === 0) {
return;
}
const isPathspecError =
/pathspec/.test(result.stderr) && /did not match any files/.test(result.stderr);
if (!isPathspecError) {
throw new Error(result.stderr || 'Failed to stage git file');
}
// During rapid stage/unstage toggling the optimistic UI can request staging a
// path that a prior queued mutation already staged (most visibly a deletion,
// whose file is gone from the working tree). `git add` aborts the whole batch on
// a single unmatched pathspec, so retry per-path and skip the ones already in
// their target state rather than failing the entire "stage all".
for (const path of paths) {
const perPath = await execGit(['add', '--', path], directory);
if (perPath.exitCode === 0) {
continue;
}
const perPathIsPathspecError =
/pathspec/.test(perPath.stderr) && /did not match any files/.test(perPath.stderr);
if (!perPathIsPathspecError) {
throw new Error(perPath.stderr || 'Failed to stage git file');
}
}
}
export async function unstageGitFile(directory: string, filePath: string): Promise<void> {
await unstageGitFiles(directory, [filePath]);
}
export async function unstageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
if (paths.length === 0) {
throw new Error('path is required');
}
const result = await execGit(['restore', '--staged', '--', ...paths], directory);
if (result.exitCode === 0) {
return;
}
const fallback = await execGit(['reset', 'HEAD', '--', ...paths], directory);
if (fallback.exitCode !== 0) {
throw new Error(fallback.stderr || result.stderr || 'Failed to unstage git file');
}
}
// ============== Commit Operations ==============
@@ -2138,8 +2228,49 @@ export interface GitCommitResult {
export async function createGitCommit(
directory: string,
message: string,
options?: { addAll?: boolean; files?: string[] }
options?: { addAll?: boolean; files?: string[]; stageFiles?: string[] }
): Promise<GitCommitResult> {
if (options?.files?.length && options.stageFiles) {
const selectedFiles = new Set(options.files);
const stagedResult = await execGit(['diff', '--cached', '--name-only'], directory);
const temporarilyUnstagedFiles = stagedResult.stdout
.split('\n')
.map((line) => line.trim())
.filter((filePath) => filePath && !selectedFiles.has(filePath));
try {
if (temporarilyUnstagedFiles.length > 0) {
await execGit(['restore', '--staged', '--', ...temporarilyUnstagedFiles], directory);
}
if (options.stageFiles.length > 0) {
await execGit(['add', '--', ...options.stageFiles], directory);
}
const result = await execGit(['commit', '-m', message], directory);
if (result.exitCode !== 0) {
return {
success: false,
commit: '',
branch: '',
summary: { changes: 0, insertions: 0, deletions: 0 },
};
}
const hashResult = await execGit(['rev-parse', 'HEAD'], directory);
const branchResult = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], directory);
return {
success: true,
commit: hashResult.stdout.trim(),
branch: branchResult.stdout.trim(),
summary: { changes: 0, insertions: 0, deletions: 0 },
};
} finally {
if (temporarilyUnstagedFiles.length > 0) {
await execGit(['add', '--', ...temporarilyUnstagedFiles], directory);
}
}
}
const repo = await getRepository(directory);
if (repo) {
@@ -2147,7 +2278,10 @@ export async function createGitCommit(
if (options?.addAll) {
await repo.add(['.']);
} else if (options?.files?.length) {
await repo.add(options.files);
const filesToStage = options.stageFiles ?? options.files;
if (filesToStage.length > 0) {
await repo.add(filesToStage);
}
}
await repo.commit(message);
@@ -2168,7 +2302,10 @@ export async function createGitCommit(
if (options?.addAll) {
await execGit(['add', '-A'], directory);
} else if (options?.files?.length) {
await execGit(['add', ...options.files], directory);
const filesToStage = options.stageFiles ?? options.files;
if (filesToStage.length > 0) {
await execGit(['add', ...filesToStage], directory);
}
}
const result = await execGit(['commit', '-m', message], directory);
@@ -2475,6 +2612,13 @@ export async function stashGitChanges(directory: string, options: { message?: st
export async function applyGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const ref = options.ref || 'stash@{0}';
// Prefer --index so the staged/unstaged split captured in the stash is restored
// faithfully. Fall back to a plain apply when the index can't be reinstated
// cleanly (e.g. conflicts), which is the prior behavior.
const withIndex = await execGit(['stash', 'apply', '--index', ref], directory);
if (withIndex.exitCode === 0) {
return { success: true, ref };
}
const result = await execGit(['stash', 'apply', ref], directory);
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to apply stash');
return { success: true, ref };