Git - add "Stashes" node to the repositories view (#279400)

* WIP - Initial implementation

* Get author and committer date for a stash

* Add drop stash command

* More cleanup
This commit is contained in:
Ladislau Szomoru
2025-11-25 17:13:48 +00:00
committed by GitHub
parent 0432ed536c
commit f297f37463
9 changed files with 279 additions and 59 deletions

View File

@@ -4,9 +4,10 @@
*--------------------------------------------------------------------------------------------*/
import { LogOutputChannel, SourceControlArtifactProvider, SourceControlArtifactGroup, SourceControlArtifact, Event, EventEmitter, ThemeIcon, l10n, workspace, Uri, Disposable } from 'vscode';
import { dispose, fromNow, IDisposable } from './util';
import { dispose, filterEvent, fromNow, getStashDescription, IDisposable } from './util';
import { Repository } from './repository';
import { Ref, RefType } from './api/git';
import { OperationKind } from './operation';
function getArtifactDescription(ref: Ref, shortCommitLength: number): string {
const segments: string[] = [];
@@ -82,6 +83,7 @@ export class GitArtifactProvider implements SourceControlArtifactProvider, IDisp
) {
this._groups = [
{ id: 'branches', name: l10n.t('Branches'), icon: new ThemeIcon('git-branch') },
{ id: 'stashes', name: l10n.t('Stashes'), icon: new ThemeIcon('git-stash') },
{ id: 'tags', name: l10n.t('Tags'), icon: new ThemeIcon('tag') }
];
@@ -98,6 +100,15 @@ export class GitArtifactProvider implements SourceControlArtifactProvider, IDisp
this._onDidChangeArtifacts.fire(Array.from(groups));
}));
const onDidRunWriteOperation = filterEvent(
repository.onDidRunOperation, e => !e.operation.readOnly);
this._disposables.push(onDidRunWriteOperation(result => {
if (result.operation.kind === OperationKind.Stash) {
this._onDidChangeArtifacts.fire(['stashes']);
}
}));
}
provideArtifactGroups(): SourceControlArtifactGroup[] {
@@ -133,6 +144,15 @@ export class GitArtifactProvider implements SourceControlArtifactProvider, IDisp
? new ThemeIcon('target')
: new ThemeIcon('tag')
}));
} else if (group === 'stashes') {
const stashes = await this.repository.getStashes();
return stashes.map(s => ({
id: `stash@{${s.index}}`,
name: s.description,
description: getStashDescription(s),
icon: new ThemeIcon('git-stash')
}));
}
} catch (err) {
this.logger.error(`[GitArtifactProvider][provideArtifacts] Error while providing artifacts for group '${group}': `, err);

View File

@@ -14,7 +14,7 @@ import { Model } from './model';
import { GitResourceGroup, Repository, Resource, ResourceGroupType } from './repository';
import { DiffEditorSelectionHunkToolbarContext, LineChange, applyLineChanges, getIndexDiffInformation, getModifiedRange, getWorkingTreeDiffInformation, intersectDiffWithRange, invertLineChange, toLineChanges, toLineRanges, compareLineChanges } from './staging';
import { fromGitUri, toGitUri, isGitUri, toMergeUris, toMultiFileDiffEditorUris } from './uri';
import { DiagnosticSeverityConfig, dispose, fromNow, getHistoryItemDisplayName, grep, isDefined, isDescendant, isLinuxSnap, isRemote, isWindows, pathEquals, relativePath, subject, toDiagnosticSeverity, truncate } from './util';
import { DiagnosticSeverityConfig, dispose, fromNow, getHistoryItemDisplayName, getStashDescription, grep, isDefined, isDescendant, isLinuxSnap, isRemote, isWindows, pathEquals, relativePath, subject, toDiagnosticSeverity, truncate } from './util';
import { GitTimelineItem } from './timelineProvider';
import { ApiRepository } from './api/api1';
import { getRemoteSourceActions, pickRemoteSource } from './remoteSource';
@@ -333,7 +333,7 @@ class RepositoryItem implements QuickPickItem {
class StashItem implements QuickPickItem {
get label(): string { return `#${this.stash.index}: ${this.stash.description}`; }
get description(): string | undefined { return this.stash.branchName; }
get description(): string | undefined { return getStashDescription(this.stash); }
constructor(readonly stash: Stash) { }
}
@@ -4544,7 +4544,7 @@ export class CommandCenter {
return;
}
await this._stashDrop(repository, stash);
await this._stashDrop(repository, stash.index, stash.description);
}
@command('git.stashDropAll', { repository: true })
@@ -4577,15 +4577,15 @@ export class CommandCenter {
return;
}
if (await this._stashDrop(result.repository, result.stash)) {
if (await this._stashDrop(result.repository, result.stash.index, result.stash.description)) {
await commands.executeCommand('workbench.action.closeActiveEditor');
}
}
async _stashDrop(repository: Repository, stash: Stash): Promise<boolean> {
async _stashDrop(repository: Repository, index: number, description: string): Promise<boolean> {
const yes = l10n.t('Yes');
const result = await window.showWarningMessage(
l10n.t('Are you sure you want to drop the stash: {0}?', stash.description),
l10n.t('Are you sure you want to drop the stash: {0}?', description),
{ modal: true },
yes
);
@@ -4593,7 +4593,7 @@ export class CommandCenter {
return false;
}
await repository.dropStash(stash.index);
await repository.dropStash(index);
return true;
}
@@ -4606,36 +4606,7 @@ export class CommandCenter {
return;
}
const stashChanges = await repository.showStash(stash.index);
if (!stashChanges || stashChanges.length === 0) {
return;
}
// A stash commit can have up to 3 parents:
// 1. The first parent is the commit that was HEAD when the stash was created.
// 2. The second parent is the commit that represents the index when the stash was created.
// 3. The third parent (when present) represents the untracked files when the stash was created.
const stashFirstParentCommit = stash.parents.length > 0 ? stash.parents[0] : `${stash.hash}^`;
const stashUntrackedFilesParentCommit = stash.parents.length === 3 ? stash.parents[2] : undefined;
const stashUntrackedFiles: string[] = [];
if (stashUntrackedFilesParentCommit) {
const untrackedFiles = await repository.getObjectFiles(stashUntrackedFilesParentCommit);
stashUntrackedFiles.push(...untrackedFiles.map(f => path.join(repository.root, f.file)));
}
const title = `Git Stash #${stash.index}: ${stash.description}`;
const multiDiffSourceUri = toGitUri(Uri.file(repository.root), `stash@{${stash.index}}`, { scheme: 'git-stash' });
const resources: { originalUri: Uri | undefined; modifiedUri: Uri | undefined }[] = [];
for (const change of stashChanges) {
const isChangeUntracked = !!stashUntrackedFiles.find(f => pathEquals(f, change.uri.fsPath));
const modifiedUriRef = !isChangeUntracked ? stash.hash : stashUntrackedFilesParentCommit ?? stash.hash;
resources.push(toMultiFileDiffEditorUris(change, stashFirstParentCommit, modifiedUriRef));
}
commands.executeCommand('_workbench.openMultiDiffEditor', { multiDiffSourceUri, title, resources });
await this._viewStash(repository, stash);
}
private async pickStash(repository: Repository, placeHolder: string): Promise<Stash | undefined> {
@@ -4680,6 +4651,39 @@ export class CommandCenter {
return { repository, stash };
}
private async _viewStash(repository: Repository, stash: Stash): Promise<void> {
const stashChanges = await repository.showStash(stash.index);
if (!stashChanges || stashChanges.length === 0) {
return;
}
// A stash commit can have up to 3 parents:
// 1. The first parent is the commit that was HEAD when the stash was created.
// 2. The second parent is the commit that represents the index when the stash was created.
// 3. The third parent (when present) represents the untracked files when the stash was created.
const stashFirstParentCommit = stash.parents.length > 0 ? stash.parents[0] : `${stash.hash}^`;
const stashUntrackedFilesParentCommit = stash.parents.length === 3 ? stash.parents[2] : undefined;
const stashUntrackedFiles: string[] = [];
if (stashUntrackedFilesParentCommit) {
const untrackedFiles = await repository.getObjectFiles(stashUntrackedFilesParentCommit);
stashUntrackedFiles.push(...untrackedFiles.map(f => path.join(repository.root, f.file)));
}
const title = `Git Stash #${stash.index}: ${stash.description}`;
const multiDiffSourceUri = toGitUri(Uri.file(repository.root), `stash@{${stash.index}}`, { scheme: 'git-stash' });
const resources: { originalUri: Uri | undefined; modifiedUri: Uri | undefined }[] = [];
for (const change of stashChanges) {
const isChangeUntracked = !!stashUntrackedFiles.find(f => pathEquals(f, change.uri.fsPath));
const modifiedUriRef = !isChangeUntracked ? stash.hash : stashUntrackedFilesParentCommit ?? stash.hash;
resources.push(toMultiFileDiffEditorUris(change, stashFirstParentCommit, modifiedUriRef));
}
commands.executeCommand('_workbench.openMultiDiffEditor', { multiDiffSourceUri, title, resources });
}
@command('git.timeline.openDiff', { repository: false })
async timelineOpenDiff(item: TimelineItem, uri: Uri | undefined, _source: string) {
const cmd = this.resolveTimelineOpenDiffCommand(
@@ -5217,6 +5221,78 @@ export class CommandCenter {
await repository.deleteTag(artifact.name);
}
@command('git.repositories.stashView', { repository: true })
async artifactStashView(repository: Repository, artifact: SourceControlArtifact): Promise<void> {
if (!repository || !artifact) {
return;
}
// Extract stash index from artifact id
const regex = /^stash@\{(\d+)\}$/;
const match = regex.exec(artifact.id);
if (!match) {
return;
}
const stashes = await repository.getStashes();
const stash = stashes.find(s => s.index === parseInt(match[1]));
if (!stash) {
return;
}
await this._viewStash(repository, stash);
}
@command('git.repositories.stashApply', { repository: true })
async artifactStashApply(repository: Repository, artifact: SourceControlArtifact): Promise<void> {
if (!repository || !artifact) {
return;
}
// Extract stash index from artifact id (format: "stash@{index}")
const regex = /^stash@\{(\d+)\}$/;
const match = regex.exec(artifact.id);
if (!match) {
return;
}
const stashIndex = parseInt(match[1]);
await repository.applyStash(stashIndex);
}
@command('git.repositories.stashPop', { repository: true })
async artifactStashPop(repository: Repository, artifact: SourceControlArtifact): Promise<void> {
if (!repository || !artifact) {
return;
}
// Extract stash index from artifact id (format: "stash@{index}")
const regex = /^stash@\{(\d+)\}$/;
const match = regex.exec(artifact.id);
if (!match) {
return;
}
const stashIndex = parseInt(match[1]);
await repository.popStash(stashIndex);
}
@command('git.repositories.stashDrop', { repository: true })
async artifactStashDrop(repository: Repository, artifact: SourceControlArtifact): Promise<void> {
if (!repository || !artifact) {
return;
}
// Extract stash index from artifact id
const regex = /^stash@\{(\d+)\}$/;
const match = regex.exec(artifact.id);
if (!match) {
return;
}
await this._stashDrop(repository, parseInt(match[1]), artifact.name);
}
private createCommand(id: string, key: string, method: Function, options: ScmCommandOptions): (...args: any[]) => any {
const result = (...args: any[]) => {
let result: Promise<any>;

View File

@@ -44,6 +44,8 @@ export interface Stash {
readonly index: number;
readonly description: string;
readonly branchName?: string;
readonly authorDate?: Date;
readonly commitDate?: Date;
}
interface MutableRemote extends Remote {
@@ -370,7 +372,7 @@ function sanitizeRelativePath(path: string): string {
}
const COMMIT_FORMAT = '%H%n%aN%n%aE%n%at%n%ct%n%P%n%D%n%B';
const STASH_FORMAT = '%H%n%P%n%gd%n%gs';
const STASH_FORMAT = '%H%n%P%n%gd%n%gs%n%at%n%ct';
export interface ICloneOptions {
readonly parentPath: string;
@@ -999,12 +1001,12 @@ export function parseLsFiles(raw: string): LsFilesElement[] {
.map(([, mode, object, stage, file]) => ({ mode, object, stage, file }));
}
const stashRegex = /([0-9a-f]{40})\n(.*)\nstash@{(\d+)}\n(WIP\s)*on([^:]+):(.*)(?:\x00)/gmi;
const stashRegex = /([0-9a-f]{40})\n(.*)\nstash@{(\d+)}\n(WIP\s)?on\s([^:]+):\s(.*)\n(\d+)\n(\d+)(?:\x00)/gmi;
function parseGitStashes(raw: string): Stash[] {
const result: Stash[] = [];
let match, hash, parents, index, wip, branchName, description;
let match, hash, parents, index, wip, branchName, description, authorDate, commitDate;
do {
match = stashRegex.exec(raw);
@@ -1012,13 +1014,15 @@ function parseGitStashes(raw: string): Stash[] {
break;
}
[, hash, parents, index, wip, branchName, description] = match;
[, hash, parents, index, wip, branchName, description, authorDate, commitDate] = match;
result.push({
hash,
parents: parents.split(' '),
index: parseInt(index),
branchName: branchName.trim(),
description: wip ? `WIP (${description.trim()})` : description.trim()
description: wip ? `WIP (${description.trim()})` : description.trim(),
authorDate: authorDate ? new Date(Number(authorDate) * 1000) : undefined,
commitDate: commitDate ? new Date(Number(commitDate) * 1000) : undefined,
});
} while (true);

View File

@@ -191,7 +191,7 @@ export const Operation = {
Show: { kind: OperationKind.Show, blocking: false, readOnly: true, remote: false, retry: false, showProgress: false } as ShowOperation,
Stage: { kind: OperationKind.Stage, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as StageOperation,
Status: { kind: OperationKind.Status, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as StatusOperation,
Stash: { kind: OperationKind.Stash, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as StashOperation,
Stash: (readOnly: boolean) => ({ kind: OperationKind.Stash, blocking: false, readOnly, remote: false, retry: false, showProgress: true } as StashOperation),
SubmoduleUpdate: { kind: OperationKind.SubmoduleUpdate, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as SubmoduleUpdateOperation,
Sync: { kind: OperationKind.Sync, blocking: true, readOnly: false, remote: true, retry: true, showProgress: true } as SyncOperation,
Tag: { kind: OperationKind.Tag, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as TagOperation,

View File

@@ -2253,7 +2253,7 @@ export class Repository implements Disposable {
}
async getStashes(): Promise<Stash[]> {
return this.run(Operation.Stash, () => this.repository.getStashes());
return this.run(Operation.Stash(true), () => this.repository.getStashes());
}
async createStash(message?: string, includeUntracked?: boolean, staged?: boolean): Promise<void> {
@@ -2262,26 +2262,26 @@ export class Repository implements Disposable {
...!staged ? this.workingTreeGroup.resourceStates.map(r => r.resourceUri.fsPath) : [],
...includeUntracked ? this.untrackedGroup.resourceStates.map(r => r.resourceUri.fsPath) : []];
return await this.run(Operation.Stash, async () => {
return await this.run(Operation.Stash(false), async () => {
await this.repository.createStash(message, includeUntracked, staged);
this.closeDiffEditors(indexResources, workingGroupResources);
});
}
async popStash(index?: number, options?: { reinstateStagedChanges?: boolean }): Promise<void> {
return await this.run(Operation.Stash, () => this.repository.popStash(index, options));
return await this.run(Operation.Stash(false), () => this.repository.popStash(index, options));
}
async dropStash(index?: number): Promise<void> {
return await this.run(Operation.Stash, () => this.repository.dropStash(index));
return await this.run(Operation.Stash(false), () => this.repository.dropStash(index));
}
async applyStash(index?: number, options?: { reinstateStagedChanges?: boolean }): Promise<void> {
return await this.run(Operation.Stash, () => this.repository.applyStash(index, options));
return await this.run(Operation.Stash(false), () => this.repository.applyStash(index, options));
}
async showStash(index: number): Promise<Change[] | undefined> {
return await this.run(Operation.Stash, () => this.repository.showStash(index));
return await this.run(Operation.Stash(true), () => this.repository.showStash(index));
}
async getCommitTemplate(): Promise<string> {

View File

@@ -8,6 +8,7 @@ import { dirname, normalize, sep, relative } from 'path';
import { Readable } from 'stream';
import { promises as fs, createReadStream } from 'fs';
import byline from 'byline';
import { Stash } from './git';
export const isMacintosh = process.platform === 'darwin';
export const isWindows = process.platform === 'win32';
@@ -846,3 +847,19 @@ export function extractFilePathFromArgs(argv: string[], startIndex: number): str
// leading quote and return the path as-is
return path.slice(1);
}
export function getStashDescription(stash: Stash): string | undefined {
if (!stash.commitDate && !stash.branchName) {
return undefined;
}
const descriptionSegments: string[] = [];
if (stash.commitDate) {
descriptionSegments.push(fromNow(stash.commitDate));
}
if (stash.branchName) {
descriptionSegments.push(stash.branchName);
}
return descriptionSegments.join(' \u2022 ');
}