diff --git a/extensions/git/package.json b/extensions/git/package.json index a117e45d025..7d89571372b 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -3325,6 +3325,11 @@ "type": "boolean", "default": true, "markdownDescription": "%config.discardUntrackedChangesToTrash%" + }, + "git.showReferenceDetails": { + "type": "boolean", + "default": false, + "markdownDescription": "%config.showReferenceDetails%" } } }, diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 101618a8e45..52ba3819817 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -289,6 +289,7 @@ "config.diagnosticsCommitHook.Enabled": "Controls whether to check for unresolved diagnostics before committing.", "config.diagnosticsCommitHook.Sources": "Controls the list of sources (**Item**) and the minimum severity (**Value**) to be considered before committing. **Note:** To ignore diagnostics from a particular source, add the source to the list and set the minimum severity to `none`.", "config.discardUntrackedChangesToTrash": "Controls whether discarding untracked changes moves the file(s) to the Recycle Bin (Windows), Trash (macOS, Linux) instead of deleting them permanently. **Note:** This setting has no effect when connected to a remote or when running in Linux as a snap package.", + "config.showReferenceDetails": "Controls whether to show the details of the last commit for Git refs in the checkout, branch, and tag pickers.", "submenu.explorer": "Git", "submenu.commit": "Commit", "submenu.commit.amend": "Amend", diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 5bbb4b03a9c..9562106f9c6 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -30,6 +30,7 @@ export interface Ref { readonly type: RefType; readonly name?: string; readonly commit?: string; + readonly commitDetails?: Commit; readonly remote?: string; } @@ -185,6 +186,7 @@ export interface RefQuery { readonly contains?: string; readonly count?: number; readonly pattern?: string | string[]; + readonly includeCommitDetails?: boolean; readonly sort?: 'alphabetically' | 'committerdate'; } diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index d9464955939..1094a438b07 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -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 } from './staging'; import { fromGitUri, toGitUri, isGitUri, toMergeUris, toMultiFileDiffEditorUris } from './uri'; -import { DiagnosticSeverityConfig, dispose, getCommitShortHash, grep, isDefined, isDescendant, isLinuxSnap, isRemote, isWindows, pathEquals, relativePath, toDiagnosticSeverity, truncate } from './util'; +import { DiagnosticSeverityConfig, dispose, fromNow, getCommitShortHash, grep, isDefined, isDescendant, isLinuxSnap, isRemote, isWindows, pathEquals, relativePath, toDiagnosticSeverity, truncate } from './util'; import { GitTimelineItem } from './timelineProvider'; import { ApiRepository } from './api/api1'; import { getRemoteSourceActions, pickRemoteSource } from './remoteSource'; @@ -73,6 +73,10 @@ class RefItem implements QuickPickItem { } get description(): string { + if (this.ref.commitDetails?.authorDate) { + return fromNow(this.ref.commitDetails.authorDate, true, true); + } + switch (this.ref.type) { case RefType.Head: return this.shortCommit; @@ -85,6 +89,14 @@ class RefItem implements QuickPickItem { } } + get detail(): string | undefined { + if (this.ref.commitDetails?.authorName && this.ref.commitDetails?.message) { + return `${this.ref.commitDetails?.authorName} | ${this.ref.commitDetails?.message}`; + } + + return undefined; + } + get refName(): string | undefined { return this.ref.name; } get refRemote(): string | undefined { return this.ref.remote; } get shortCommit(): string { return (this.ref.commit || '').substr(0, 8); } @@ -326,6 +338,8 @@ async function categorizeResourceByResolution(resources: Resource[]): Promise<{ async function createCheckoutItems(repository: Repository, detached = false): Promise { const config = workspace.getConfiguration('git'); const checkoutTypeConfig = config.get('checkoutType'); + const showRefDetails = config.get('showReferenceDetails') === true; + let checkoutTypes: string[]; if (checkoutTypeConfig === 'all' || !checkoutTypeConfig || checkoutTypeConfig.length === 0) { @@ -341,7 +355,7 @@ async function createCheckoutItems(repository: Repository, detached = false): Pr checkoutTypes = checkoutTypes.filter(t => t !== 'tags'); } - const refs = await repository.getRefs(); + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); const refProcessors = checkoutTypes.map(type => getCheckoutRefProcessor(repository, type)) .filter(p => !!p) as RefProcessor[]; @@ -2696,6 +2710,7 @@ export class CommandCenter { const quickPick = window.createQuickPick(); quickPick.busy = true; quickPick.sortByLabel = false; + quickPick.matchOnDetail = true; quickPick.placeholder = opts?.detached ? l10n.t('Select a branch to checkout in detached mode') : l10n.t('Select a branch or tag to checkout'); @@ -2916,9 +2931,12 @@ export class CommandCenter { private async _branch(repository: Repository, defaultName?: string, from = false, target?: string): Promise { target = target ?? 'HEAD'; + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + if (from) { const getRefPicks = async () => { - const refs = await repository.getRefs(); + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); const refProcessors = new RefItemsProcessor([ new RefProcessor(RefType.Head), new RefProcessor(RefType.RemoteHead), @@ -3021,6 +3039,9 @@ export class CommandCenter { private async _deleteBranch(repository: Repository, remote: string | undefined, name: string | undefined, options: { remote: boolean; force?: boolean }): Promise { let run: (force?: boolean) => Promise; + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + if (!options.remote && typeof name === 'string') { // Local branch run = force => repository.deleteBranch(name!, force); @@ -3030,7 +3051,7 @@ export class CommandCenter { } else { const getBranchPicks = async () => { const pattern = options.remote ? 'refs/remotes' : 'refs/heads'; - const refs = await repository.getRefs({ pattern }); + const refs = await repository.getRefs({ pattern, includeCommitDetails: showRefDetails }); const refsToExclude: string[] = []; if (options.remote) { @@ -3108,8 +3129,11 @@ export class CommandCenter { @command('git.merge', { repository: true }) async merge(repository: Repository): Promise { + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + const getQuickPickItems = async (): Promise => { - const refs = await repository.getRefs(); + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); const itemsProcessor = new RefItemsProcessor([ new RefProcessor(RefType.Head, MergeItem), new RefProcessor(RefType.RemoteHead, MergeItem), @@ -3134,8 +3158,11 @@ export class CommandCenter { @command('git.rebase', { repository: true }) async rebase(repository: Repository): Promise { + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + const getQuickPickItems = async (): Promise => { - const refs = await repository.getRefs(); + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); const itemsProcessor = new RebaseItemsProcessors(repository); return itemsProcessor.processRefs(refs); @@ -3173,8 +3200,11 @@ export class CommandCenter { @command('git.deleteTag', { repository: true }) async deleteTag(repository: Repository): Promise { + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + const tagPicks = async (): Promise => { - const remoteTags = await repository.getRefs({ pattern: 'refs/tags' }); + const remoteTags = await repository.getRefs({ pattern: 'refs/tags', includeCommitDetails: showRefDetails }); return remoteTags.length === 0 ? [{ label: l10n.t('$(info) This repository has no tags.') }] : remoteTags.map(ref => new TagDeleteItem(ref)); }; diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 09af8d0c3a5..4f64607aa6a 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -15,7 +15,7 @@ import * as filetype from 'file-type'; import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter, Versions, isWindows, pathEquals, isMacintosh, isDescendant, relativePath } from './util'; import { CancellationError, CancellationToken, ConfigurationChangeEvent, LogOutputChannel, Progress, Uri, workspace } from 'vscode'; import { detectEncoding } from './encoding'; -import { Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, RefQuery, InitOptions } from './api/git'; +import { Commit as ApiCommit, Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, RefQuery, InitOptions } from './api/git'; import * as byline from 'byline'; import { StringDecoder } from 'string_decoder'; @@ -1130,6 +1130,70 @@ function parseGitBlame(data: string): BlameInformation[] { return Array.from(blameInformation.values()); } +const REFS_FORMAT = '%(refname)%00%(objectname)%00%(*objectname)'; +const REFS_WITH_DETAILS_FORMAT = `${REFS_FORMAT}%00%(parent)%00%(*parent)%00%(authorname)%00%(*authorname)%00%(authordate:unix)%00%(*authordate:unix)%00%(subject)%00%(*subject)`; + +const headRegex = /^refs\/heads\/([^ ]+)$/; +const remoteHeadRegex = /^refs\/remotes\/([^/]+)\/([^ ]+)$/; +const tagRegex = /^refs\/tags\/([^ ]+)$/; + +function parseRefs(data: string, includeCommitDetails: boolean): Ref[] { + const refs: Ref[] = []; + const refRegex = !includeCommitDetails + ? /^(.*)\0([0-9a-f]{40})\0([0-9a-f]{40})?$/gm + : /^(.*)\0([0-9a-f]{40})\0([0-9a-f]{40})?\0(.*)\0(.*)\0(.*)\0(.*)\0(.*)\0(.*)\0(.*)\0(.*)$/gm; + + let ref: string | undefined; + let commitHash: string | undefined; + let tagCommitHash: string | undefined; + let commitParents: string | undefined; + let tagCommitParents: string | undefined; + let commitSubject: string | undefined; + let tagCommitSubject: string | undefined; + let authorName: string | undefined; + let tagAuthorName: string | undefined; + let authorDate: string | undefined; + let tagAuthorDate: string | undefined; + + let match: RegExpExecArray | null; + let refMatch: RegExpExecArray | null; + + do { + match = refRegex.exec(data); + if (match === null) { + break; + } + + let commitDetails: ApiCommit | undefined = undefined; + [, ref, commitHash, tagCommitHash, commitParents, tagCommitParents, authorName, tagAuthorName, authorDate, tagAuthorDate, commitSubject, tagCommitSubject] = match; + + if (includeCommitDetails) { + const parents = tagCommitParents || commitParents; + const subject = tagCommitSubject || commitSubject; + const author = tagAuthorName || authorName; + const date = tagAuthorDate || authorDate; + + commitDetails = { + hash: commitHash, + message: subject, + parents: parents ? parents.split(' ') : [], + authorName: author, + authorDate: date ? new Date(Number(date) * 1000) : undefined + } satisfies ApiCommit; + } + + if (refMatch = headRegex.exec(ref)) { + refs.push({ name: refMatch[1], commit: commitHash, commitDetails, type: RefType.Head }); + } else if (refMatch = remoteHeadRegex.exec(ref)) { + refs.push({ name: `${refMatch[1]}/${refMatch[2]}`, remote: refMatch[1], commit: commitHash, commitDetails, type: RefType.RemoteHead }); + } else if (refMatch = tagRegex.exec(ref)) { + refs.push({ name: refMatch[1], commit: tagCommitHash ?? commitHash, commitDetails, type: RefType.Tag }); + } + } while (true); + + return refs; +} + export interface PullOptions { readonly unshallow?: boolean; readonly tags?: boolean; @@ -2585,7 +2649,7 @@ export class Repository { args.push('--sort', `-${query.sort}`); } - args.push('--format', '%(refname) %(objectname) %(*objectname)'); + args.push('--format', query.includeCommitDetails ? REFS_WITH_DETAILS_FORMAT : REFS_FORMAT); if (query.pattern) { const patterns = Array.isArray(query.pattern) ? query.pattern : [query.pattern]; @@ -2599,25 +2663,7 @@ export class Repository { } const result = await this.exec(args, { cancellationToken }); - - const fn = (line: string): Ref | null => { - let match: RegExpExecArray | null; - - if (match = /^refs\/heads\/([^ ]+) ([0-9a-f]{40}) ([0-9a-f]{40})?$/.exec(line)) { - return { name: match[1], commit: match[2], type: RefType.Head }; - } else if (match = /^refs\/remotes\/([^/]+)\/([^ ]+) ([0-9a-f]{40}) ([0-9a-f]{40})?$/.exec(line)) { - return { name: `${match[1]}/${match[2]}`, commit: match[3], type: RefType.RemoteHead, remote: match[1] }; - } else if (match = /^refs\/tags\/([^ ]+) ([0-9a-f]{40}) ([0-9a-f]{40})?$/.exec(line)) { - return { name: match[1], commit: match[3] ?? match[2], type: RefType.Tag }; - } - - return null; - }; - - return result.stdout.split('\n') - .filter(line => !!line) - .map(fn) - .filter(ref => !!ref) as Ref[]; + return parseRefs(result.stdout, query.includeCommitDetails === true); } async getRemoteRefs(remote: string, opts?: { heads?: boolean; tags?: boolean; cancellationToken?: CancellationToken }): Promise {