diff --git a/CHANGELOG.md b/CHANGELOG.md index 2122d84..3c03f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## Changed +- Improves how stashes are shown in the _Stashes_ view by separating the associated branch from the stash message — closes [#1523](https://github.com/gitkraken/vscode-gitlens/issues/1523) - Changes previous Gerrit remote support to Google Source remote support — thanks to [PR #1954](https://github.com/gitkraken/vscode-gitlens/pull/1954) by Felipe Santos ([@felipecrs](https://github.com/felipecrs)) - Renames "Gutter Blame" annotations to "File Blame" - Renames "Gutter Changes" annotations to "File Changes" diff --git a/package.json b/package.json index aaf988d..3532835 100644 --- a/package.json +++ b/package.json @@ -780,7 +780,7 @@ }, "gitlens.views.formats.stashes.description": { "type": "string", - "default": "${agoOrDate}", + "default": "${stashOnRef, }${agoOrDate}", "markdownDescription": "Specifies the description format of stashes in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", "scope": "window", "order": 35 diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts index 01aa5e9..0ced5ff 100644 --- a/src/env/node/git/git.ts +++ b/src/env/node/git/git.ts @@ -3,7 +3,7 @@ import { hrtime } from '@env/hrtime'; import { GlyphChars } from '../../../constants'; import { GitCommandOptions, GitErrorHandling } from '../../../git/commandOptions'; import { GitDiffFilter, GitRevision, GitUser } from '../../../git/models'; -import { GitBranchParser, GitLogParser, GitReflogParser, GitStashParser, GitTagParser } from '../../../git/parsers'; +import { GitBranchParser, GitLogParser, GitReflogParser, GitTagParser } from '../../../git/parsers'; import { Logger } from '../../../logger'; import { dirname, isAbsolute, isFolderGlob, joinPaths, normalizePath, splitPath } from '../../../system/path'; import { getDurationMilliseconds } from '../../../system/string'; @@ -1386,18 +1386,18 @@ export class Git { stash__list( repoPath: string, - { - format = GitStashParser.defaultFormat, - similarityThreshold, - }: { format?: string; similarityThreshold?: number | null } = {}, + { args, similarityThreshold }: { args?: string[]; similarityThreshold?: number | null }, ) { + if (args == null) { + args = ['--name-status']; + } + return this.git( { cwd: repoPath }, 'stash', 'list', - '--name-status', + ...args, `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`, - `--format=${format}`, ); } diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 99c56d4..52d2d0f 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -61,12 +61,15 @@ import { GitBranch, GitBranchReference, GitCommit, + GitCommitIdentity, GitContributor, GitDiff, GitDiffFilter, GitDiffHunkLine, GitDiffShortStat, GitFile, + GitFileChange, + GitFileStatus, GitLog, GitMergeStatus, GitRebaseStatus, @@ -75,6 +78,7 @@ import { GitRemote, GitRevision, GitStash, + GitStashCommit, GitStatus, GitStatusFile, GitTag, @@ -95,7 +99,6 @@ import { GitLogParser, GitReflogParser, GitRemoteParser, - GitStashParser, GitStatusParser, GitTagParser, GitTreeParser, @@ -150,6 +153,8 @@ const doubleQuoteRegex = /"/g; const driveLetterRegex = /(?<=^\/?)([a-zA-Z])(?=:\/)/; const userConfigRegex = /^user\.(name|email) (.*)$/gm; const mappedAuthorRegex = /(.+)\s<(.+)>/; +const stashSummaryRegex = + /(?:(?:(?WIP) on|On) (?[^/](?!.*\/\.)(?!.*\.\.)(?!.*\/\/)(?!.*@\{)[^\000-\037\177 ~^:?*[\\]+[^./]):\s*)?(?.*)$/s; const reflogCommands = ['merge', 'pull']; @@ -3226,10 +3231,71 @@ export class LocalGitProvider implements GitProvider, Disposable { let stash = this.useCaching ? this._stashesCache.get(repoPath) : undefined; if (stash === undefined) { + const parser = GitLogParser.createWithFiles<{ + sha: string; + date: string; + committedDate: string; + stashName: string; + summary: string; + }>({ + sha: '%H', + date: '%at', + committedDate: '%ct', + stashName: '%gd', + summary: '%B', + }); const data = await this.git.stash__list(repoPath, { + args: parser.arguments, similarityThreshold: this.container.config.advanced.similarityThreshold, }); - stash = GitStashParser.parse(this.container, data, repoPath); + + const commits = new Map(); + + const stashes = parser.parse(data); + for (const s of stashes) { + let onRef; + let summary; + let message; + const match = stashSummaryRegex.exec(s.summary); + if (match?.groups != null) { + onRef = match.groups.onref; + if (match.groups.wip) { + message = `WIP: ${match.groups.summary.trim()}`; + summary = `WIP on ${onRef}`; + } else { + message = match.groups.summary.trim(); + summary = message.split('\n', 1)[0] ?? ''; + } + } else { + message = s.summary.trim(); + summary = message.split('\n', 1)[0] ?? ''; + } + + commits.set( + s.sha, + new GitCommit( + this.container, + repoPath, + s.sha, + new GitCommitIdentity('You', undefined, new Date((s.date as any) * 1000)), + new GitCommitIdentity('You', undefined, new Date((s.committedDate as any) * 1000)), + summary, + [], + message, + s.files?.map( + f => new GitFileChange(repoPath, f.path, f.status as GitFileStatus, f.originalPath), + ) ?? [], + undefined, + [], + s.stashName, + onRef, + ) as GitStashCommit, + ); + } + + stash = { repoPath: repoPath, commits: commits }; + + // sw.stop(); if (this.useCaching) { this._stashesCache.set(repoPath, stash ?? null); diff --git a/src/git/formatters/commitFormatter.ts b/src/git/formatters/commitFormatter.ts index 684d9c0..75ea35d 100644 --- a/src/git/formatters/commitFormatter.ts +++ b/src/git/formatters/commitFormatter.ts @@ -77,6 +77,9 @@ export interface CommitFormatOptions extends FormatOptions { pullRequestDate?: TokenOptions; pullRequestState?: TokenOptions; sha?: TokenOptions; + stashName?: TokenOptions; + stashNumber?: TokenOptions; + stashOnRef?: TokenOptions; tips?: TokenOptions; }; } @@ -590,6 +593,18 @@ export class CommitFormatter extends Formatter { return this._padOrTruncate(this._item.shortSha ?? '', this._options.tokenOptions.sha); } + get stashName(): string { + return this._padOrTruncate(this._item.stashName ?? '', this._options.tokenOptions.stashName); + } + + get stashNumber(): string { + return this._padOrTruncate(this._item.number ?? '', this._options.tokenOptions.stashNumber); + } + + get stashOnRef(): string { + return this._padOrTruncate(this._item.stashOnRef ?? '', this._options.tokenOptions.stashOnRef); + } + get tips(): string { let branchAndTagTips = this._options.getBranchAndTagTips?.(this._item.sha, { icons: this._options.markdown }); if (branchAndTagTips != null && this._options.markdown) { diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index a42082d..a23ab8a 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -49,6 +49,7 @@ export class GitCommit implements GitRevisionReference { readonly stashName: string | undefined; // TODO@eamodio rename to stashNumber readonly number: string | undefined; + readonly stashOnRef: string | undefined; constructor( private readonly container: Container, @@ -63,11 +64,20 @@ export class GitCommit implements GitRevisionReference { stats?: GitCommitStats, lines?: GitCommitLine | GitCommitLine[] | undefined, stashName?: string | undefined, + stashOnRef?: string | undefined, ) { this.ref = this.sha; - this.refType = stashName ? 'stash' : 'revision'; this.shortSha = this.sha.substring(0, this.container.CommitShaFormatting.length); + if (stashName) { + this.refType = 'stash'; + this.stashName = stashName || undefined; + this.stashOnRef = stashOnRef || undefined; + this.number = stashNumberRegex.exec(stashName)?.[1]; + } else { + this.refType = 'revision'; + } + // Add an ellipsis to the summary if there is or might be more message if (message != null) { this._message = message; @@ -115,11 +125,6 @@ export class GitCommit implements GitRevisionReference { } else { this.lines = []; } - - if (stashName) { - this.stashName = stashName || undefined; - this.number = stashNumberRegex.exec(stashName)?.[1]; - } } get date(): Date { diff --git a/src/git/parsers.ts b/src/git/parsers.ts index b470c6b..28a0a8c 100644 --- a/src/git/parsers.ts +++ b/src/git/parsers.ts @@ -4,7 +4,6 @@ export * from './parsers/diffParser'; export * from './parsers/logParser'; export * from './parsers/reflogParser'; export * from './parsers/remoteParser'; -export * from './parsers/stashParser'; export * from './parsers/statusParser'; export * from './parsers/tagParser'; export * from './parsers/treeParser'; diff --git a/src/git/parsers/logParser.ts b/src/git/parsers/logParser.ts index 03e8ff5..52576c7 100644 --- a/src/git/parsers/logParser.ts +++ b/src/git/parsers/logParser.ts @@ -169,7 +169,7 @@ export class GitLogParser { const fields = getLines(data, options?.separator ?? '\0'); if (options?.skip) { for (let i = 0; i < options.skip; i++) { - field = fields.next(); + fields.next(); } } @@ -213,9 +213,9 @@ export class GitLogParser { return { arguments: args, parse: parse }; } - static createWithFiles>(fieldMapping: T): ParserWithFiles { - let format = '%x00%x00'; - const keys: (keyof T)[] = []; + static createWithFiles>(fieldMapping: ExtractAll): ParserWithFiles { + let format = '%x00'; + const keys: (keyof ExtractAll)[] = []; for (const key in fieldMapping) { keys.push(key); format += `%x00${fieldMapping[key]}`; @@ -224,24 +224,21 @@ export class GitLogParser { const args = ['-z', `--format=${format}`, '--name-status']; function* parse(data: string): Generator> { - const records = getLines(data, '\0\0\0\0'); + const records = getLines(data, '\0\0\0'); let entry: ParsedEntryWithFiles; let files: ParsedEntryFile[]; let fields: IterableIterator; - let first = true; - for (let record of records) { - if (first) { - first = false; - // Fix the first record (since it only has 3 nulls) - record = record.slice(3); - } - + for (const record of records) { entry = {} as any; files = []; fields = getLines(record, '\0'); + // Skip the 2 starting NULs + fields.next(); + fields.next(); + let fieldCount = 0; let field; while (true) { @@ -259,6 +256,8 @@ export class GitLogParser { field = fields.next(); file.originalPath = field.value; } + + files.push(file); } } diff --git a/src/git/parsers/stashParser.ts b/src/git/parsers/stashParser.ts deleted file mode 100644 index 593f617..0000000 --- a/src/git/parsers/stashParser.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { Container } from '../../container'; -import { filterMap } from '../../system/array'; -import { debug } from '../../system/decorators/log'; -import { normalizePath } from '../../system/path'; -import { getLines } from '../../system/string'; -import { - GitCommit, - GitCommitIdentity, - GitFile, - GitFileChange, - GitFileIndexStatus, - GitStash, - GitStashCommit, -} from '../models'; -import { fileStatusRegex } from './logParser'; -// import { Logger } from './logger'; - -// Using %x00 codes because some shells seem to try to expand things if not -const lb = '%x3c'; // `%x${'<'.charCodeAt(0).toString(16)}`; -const rb = '%x3e'; // `%x${'>'.charCodeAt(0).toString(16)}`; -const sl = '%x2f'; // `%x${'/'.charCodeAt(0).toString(16)}`; -const sp = '%x20'; // `%x${' '.charCodeAt(0).toString(16)}`; - -interface StashEntry { - ref?: string; - date?: string; - committedDate?: string; - fileNames?: string; - files?: GitFile[]; - summary?: string; - stashName?: string; -} - -export class GitStashParser { - static defaultFormat = [ - `${lb}${sl}f${rb}`, - `${lb}r${rb}${sp}%H`, // ref - `${lb}d${rb}${sp}%at`, // date - `${lb}c${rb}${sp}%ct`, // committed date - `${lb}l${rb}${sp}%gd`, // reflog-selector - `${lb}s${rb}`, - '%B', // summary - `${lb}${sl}s${rb}`, - `${lb}f${rb}`, - ].join('%n'); - - @debug({ args: false, singleLine: true }) - static parse(container: Container, data: string, repoPath: string): GitStash | undefined { - if (!data) return undefined; - - const lines = getLines(`${data}`); - // Skip the first line since it will always be - let next = lines.next(); - if (next.done) return undefined; - - if (repoPath !== undefined) { - repoPath = normalizePath(repoPath); - } - - const commits = new Map(); - - let entry: StashEntry = {}; - let line: string | undefined = undefined; - let token: number; - - let match; - let renamedFileName; - - while (true) { - next = lines.next(); - if (next.done) break; - - line = next.value; - - // <<1-char token>> - // e.g. bd1452a2dc - token = line.charCodeAt(1); - - switch (token) { - case 114: // 'r': // ref - entry = { - ref: line.substring(4), - }; - break; - - case 100: // 'd': // author-date - entry.date = line.substring(4); - break; - - case 99: // 'c': // committer-date - entry.committedDate = line.substring(4); - break; - - case 108: // 'l': // reflog-selector - entry.stashName = line.substring(4); - break; - - case 115: // 's': // summary - while (true) { - next = lines.next(); - if (next.done) break; - - line = next.value; - if (line === '') break; - - if (entry.summary === undefined) { - entry.summary = line; - } else { - entry.summary += `\n${line}`; - } - } - - // Remove the trailing newline - if (entry.summary != null && entry.summary.charCodeAt(entry.summary.length - 1) === 10) { - entry.summary = entry.summary.slice(0, -1); - } - break; - - case 102: // 'f': // files - // Skip the blank line git adds before the files - next = lines.next(); - if (!next.done && next.value !== '') { - while (true) { - next = lines.next(); - if (next.done) break; - - line = next.value; - if (line === '') break; - - if (line.startsWith('warning:')) continue; - - match = fileStatusRegex.exec(line); - if (match != null) { - if (entry.files === undefined) { - entry.files = []; - } - - renamedFileName = match[3]; - if (renamedFileName !== undefined) { - entry.files.push({ - status: match[1] as GitFileIndexStatus, - path: renamedFileName, - originalPath: match[2], - }); - } else { - entry.files.push({ - status: match[1] as GitFileIndexStatus, - path: match[2], - }); - } - } - } - - if (entry.files != null) { - entry.fileNames = filterMap(entry.files, f => (f.path ? f.path : undefined)).join(', '); - } - } - - GitStashParser.parseEntry(container, entry, repoPath, commits); - entry = {}; - } - } - - const stash: GitStash = { - repoPath: repoPath, - commits: commits, - }; - return stash; - } - - private static parseEntry( - container: Container, - entry: StashEntry, - repoPath: string, - commits: Map, - ) { - let commit = commits.get(entry.ref!); - if (commit == null) { - commit = new GitCommit( - container, - repoPath, - entry.ref!, - new GitCommitIdentity('You', undefined, new Date((entry.date! as any) * 1000)), - new GitCommitIdentity('You', undefined, new Date((entry.committedDate! as any) * 1000)), - entry.summary?.split('\n', 1)[0] ?? '', - [], - entry.summary ?? '', - entry.files?.map(f => new GitFileChange(repoPath, f.path, f.status, f.originalPath)) ?? [], - undefined, - [], - entry.stashName, - ) as GitStashCommit; - } - - commits.set(entry.ref!, commit); - } -} diff --git a/src/views/nodes/stashNode.ts b/src/views/nodes/stashNode.ts index bae90b6..1137750 100644 --- a/src/views/nodes/stashNode.ts +++ b/src/views/nodes/stashNode.ts @@ -66,10 +66,14 @@ export class StashNode extends ViewRefNode