diff --git a/package.json b/package.json index e29b4fc..b0037c7 100644 --- a/package.json +++ b/package.json @@ -2084,6 +2084,11 @@ "category": "GitLens" }, { + "command": "gitlens.gitCommands", + "title": "Git Commands", + "category": "GitLens" + }, + { "command": "gitlens.switchMode", "title": "Switch Mode", "category": "GitLens" @@ -3182,6 +3187,10 @@ "when": "gitlens:enabled && gitlens:canToggleCodeLens" }, { + "command": "gitlens.gitCommands", + "when": "gitlens:enabled && !gitlens:readonly" + }, + { "command": "gitlens.switchMode", "when": "gitlens:enabled" }, diff --git a/src/commands.ts b/src/commands.ts index 193bed6..c44f005 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -28,6 +28,7 @@ export * from './commands/openFileRevisionFrom'; export * from './commands/openInRemote'; export * from './commands/openRepoInRemote'; export * from './commands/openWorkingFile'; +export * from './commands/gitCommands'; export * from './commands/repositories'; export * from './commands/resetSuppressedWarnings'; export * from './commands/searchCommits'; diff --git a/src/commands/common.ts b/src/commands/common.ts index b16295d..29dae04 100644 --- a/src/commands/common.ts +++ b/src/commands/common.ts @@ -68,6 +68,7 @@ export enum Commands { OpenWorkingFile = 'gitlens.openWorkingFile', PullRepositories = 'gitlens.pullRepositories', PushRepositories = 'gitlens.pushRepositories', + GitCommands = 'gitlens.gitCommands', ResetSuppressedWarnings = 'gitlens.resetSuppressedWarnings', ShowCommitInView = 'gitlens.showCommitInView', SearchCommits = 'gitlens.showCommitSearch', diff --git a/src/commands/gitCommands.ts b/src/commands/gitCommands.ts new file mode 100644 index 0000000..8866568 --- /dev/null +++ b/src/commands/gitCommands.ts @@ -0,0 +1,195 @@ +'use strict'; +import { Disposable, QuickInputButtons, QuickPickItem, window } from 'vscode'; +import { command, Command, Commands } from './common'; +import { log } from '../system'; +import { CherryPickQuickCommand } from './quick/cherry-pick'; +import { QuickCommandBase, QuickPickStep } from './quick/quickCommand'; +import { FetchQuickCommand } from './quick/fetch'; +import { MergeQuickCommand } from './quick/merge'; +import { PushQuickCommand } from './quick/push'; +import { PullQuickCommand } from './quick/pull'; +import { CheckoutQuickCommand } from './quick/checkout'; +import { RebaseQuickCommand } from './quick/rebase'; + +const sanitizeLabel = /\$\(.+?\)|\W/g; + +@command() +export class GitCommandsCommand extends Command { + constructor() { + super(Commands.GitCommands); + } + + @log({ args: false, correlate: true, singleLine: true, timed: false }) + async execute() { + const commands: QuickCommandBase[] = [ + new CheckoutQuickCommand(), + new CherryPickQuickCommand(), + new MergeQuickCommand(), + new FetchQuickCommand(), + new PullQuickCommand(), + new PushQuickCommand(), + new RebaseQuickCommand() + ]; + + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = true; + + let inCommand: QuickCommandBase | undefined; + + function showCommand(command: QuickPickStep | undefined) { + if (command === undefined) { + const previousLabel = inCommand && inCommand.label; + inCommand = undefined; + + quickpick.buttons = []; + quickpick.title = 'GitLens'; + quickpick.placeholder = 'Select command...'; + quickpick.canSelectMany = false; + quickpick.items = commands; + + if (previousLabel) { + const active = quickpick.items.find(i => i.label === previousLabel); + if (active) { + quickpick.activeItems = [active]; + } + } + } + else { + quickpick.buttons = command.buttons || [QuickInputButtons.Back]; + quickpick.title = command.title; + quickpick.placeholder = command.placeholder; + quickpick.canSelectMany = Boolean(command.multiselect); + + quickpick.items = command.items; + + if (quickpick.canSelectMany) { + quickpick.selectedItems = command.selectedItems || quickpick.items.filter(i => i.picked); + quickpick.activeItems = quickpick.selectedItems; + } + else { + quickpick.activeItems = command.selectedItems || quickpick.items.filter(i => i.picked); + } + + // // BUG: https://github.com/microsoft/vscode/issues/75046 + // // If we can multiselect, then ensure the selectedItems gets reset (otherwise it could end up included the current selected items) + // if (quickpick.canSelectMany && quickpick.selectedItems.length !== 0) { + // quickpick.selectedItems = []; + // } + } + } + + async function next(command: QuickCommandBase, items: QuickPickItem[] | undefined) { + quickpick.busy = true; + // quickpick.enabled = false; + + const next = await command.next(items); + if (next.done) { + return false; + } + + quickpick.value = ''; + showCommand(next.value); + + // quickpick.enabled = true; + quickpick.busy = false; + + return true; + } + + showCommand(undefined); + + const disposables: Disposable[] = []; + + try { + void (await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve()), + quickpick.onDidTriggerButton(async e => { + if (e === QuickInputButtons.Back) { + quickpick.value = ''; + if (inCommand !== undefined) { + showCommand(await inCommand.previous()); + } + + return; + } + + const step = inCommand && inCommand.value; + if (step === undefined || step.onDidClickButton === undefined) return; + + step.onDidClickButton(quickpick, e); + }), + quickpick.onDidChangeValue(async e => { + if (quickpick.canSelectMany && e === ' ') { + quickpick.value = ''; + quickpick.selectedItems = + quickpick.selectedItems.length === quickpick.items.length ? [] : quickpick.items; + + return; + } + + if (e.endsWith(' ')) { + if (quickpick.canSelectMany && quickpick.selectedItems.length !== 0) { + return; + } + + const cmd = quickpick.value.toLowerCase().trim(); + + let items; + if (inCommand === undefined) { + const command = commands.find( + c => c.label.replace(sanitizeLabel, '').toLowerCase() === cmd + ); + if (command === undefined) return; + + inCommand = command; + } + else { + const step = inCommand.value; + if (step === undefined) return; + + const item = step.items.find( + i => i.label.replace(sanitizeLabel, '').toLowerCase() === cmd + ); + if (item === undefined) return; + + items = [item]; + } + + if (!(await next(inCommand, items))) { + resolve(); + } + } + }), + quickpick.onDidAccept(async () => { + let items = quickpick.selectedItems; + if (items.length === 0) { + if (!quickpick.canSelectMany || quickpick.activeItems.length === 0) return; + + items = quickpick.activeItems; + } + + if (inCommand === undefined) { + const command = items[0]; + if (!QuickCommandBase.is(command)) return; + + inCommand = command; + } + + if (!(await next(inCommand, items as QuickPickItem[]))) { + resolve(); + } + }) + ); + + quickpick.show(); + })); + + quickpick.hide(); + } + finally { + quickpick.dispose(); + disposables.forEach(d => d.dispose()); + } + } +} diff --git a/src/commands/quick/checkout.ts b/src/commands/quick/checkout.ts new file mode 100644 index 0000000..c6856a4 --- /dev/null +++ b/src/commands/quick/checkout.ts @@ -0,0 +1,161 @@ +'use strict'; +/* eslint-disable no-loop-func */ +import { ProgressLocation, QuickInputButtons, window } from 'vscode'; +import { Container } from '../../container'; +import { Repository } from '../../git/gitService'; +import { GlyphChars } from '../../constants'; +import { GitCommandBase } from './gitCommand'; +import { CommandAbortError, QuickPickStep } from './quickCommand'; +import { ReferencesQuickPickItem, RepositoryQuickPickItem } from '../../quickpicks'; +import { Strings } from '../../system'; + +interface State { + repos: Repository[]; + ref: string; +} + +export class CheckoutQuickCommand extends GitCommandBase { + constructor() { + super('checkout', 'Checkout'); + } + + async execute(state: State) { + return void (await window.withProgress( + { + location: ProgressLocation.Notification, + title: `Checking out ${ + state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repositories` + } to ${state.ref}` + }, + () => Promise.all(state.repos.map(r => r.checkout(state.ref, { progress: false }))) + )); + } + + async *steps(): AsyncIterableIterator { + const state: Partial & { counter: number } = { counter: 0 }; + let oneRepo = false; + let showTags = false; + + while (true) { + try { + if (state.repos === undefined || state.counter < 1) { + const repos = [...(await Container.git.getOrderedRepositories())]; + + if (repos.length === 1) { + oneRepo = true; + state.counter++; + state.repos = [repos[0]]; + } + else { + const step = this.createStep({ + multiselect: true, + title: this.title, + placeholder: 'Choose repositories', + items: await Promise.all( + repos.map(repo => + RepositoryQuickPickItem.create( + repo, + state.repos ? state.repos.some(r => r.id === repo.id) : undefined, + { branch: true, fetched: true, status: true } + ) + ) + ) + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + break; + } + + state.repos = selection.map(i => i.item); + } + } + + if (state.ref === undefined || state.counter < 2) { + const includeTags = showTags || state.repos.length === 1; + + const items = await this.getBranchesAndOrTags(state.repos, includeTags); + const step = this.createStep({ + title: `${this.title}${Strings.pad(GlyphChars.Dot, 2, 2)}${ + state.repos.length === 1 + ? state.repos[0].formattedName + : `${state.repos.length} repositories` + }`, + placeholder: `Choose a branch${includeTags ? ' or tag' : ''} to checkout to`, + items: items, + selectedItems: state.ref ? items.filter(ref => ref.label === state.ref) : undefined, + buttons: includeTags + ? [QuickInputButtons.Back] + : [ + QuickInputButtons.Back, + { + iconPath: { + dark: Container.context.asAbsolutePath('images/dark/icon-tag.svg') as any, + light: Container.context.asAbsolutePath('images/light/icon-tag.svg') as any + }, + tooltip: 'Show Tags' + } + ], + onDidClickButton: async (quickpick, button) => { + quickpick.busy = true; + quickpick.enabled = false; + + if (!showTags) { + showTags = true; + } + + quickpick.placeholder = `Choose a branch${showTags ? ' or tag' : ''} to checkout to`; + quickpick.buttons = [QuickInputButtons.Back]; + + quickpick.items = await this.getBranchesAndOrTags(state.repos!, showTags); + + quickpick.busy = false; + quickpick.enabled = true; + } + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + if (oneRepo) { + break; + } + + continue; + } + + state.ref = selection[0].item.ref; + } + + const step = this.createConfirmStep( + `Confirm ${this.title}${Strings.pad(GlyphChars.Dot, 2, 2)}${ + state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repositories` + } to ${state.ref}`, + [ + { + label: this.title, + description: `${state.ref}`, + detail: `Will checkout ${ + state.repos.length === 1 + ? state.repos[0].formattedName + : `${state.repos.length} repositories` + } to ${state.ref}` + } + ] + ); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + continue; + } + + this.execute(state as State); + break; + } + catch (ex) { + if (ex instanceof CommandAbortError) break; + + throw ex; + } + } + } +} diff --git a/src/commands/quick/cherry-pick.ts b/src/commands/quick/cherry-pick.ts new file mode 100644 index 0000000..9126634 --- /dev/null +++ b/src/commands/quick/cherry-pick.ts @@ -0,0 +1,169 @@ +'use strict'; +/* eslint-disable no-loop-func */ +import { Container } from '../../container'; +import { GitBranch, GitLogCommit, Repository } from '../../git/gitService'; +import { GlyphChars } from '../../constants'; +import { Iterables, Strings } from '../../system'; +import { GitCommandBase } from './gitCommand'; +import { CommandAbortError, QuickPickStep } from './quickCommand'; +import { BranchQuickPickItem, CommitQuickPickItem, RepositoryQuickPickItem } from '../../quickpicks'; +import { runGitCommandInTerminal } from '../../terminal'; + +interface State { + repo: Repository; + destination: GitBranch; + source: GitBranch; + commits: GitLogCommit[]; +} + +export class CherryPickQuickCommand extends GitCommandBase { + constructor() { + super('cherry-pick', 'Cherry Pick'); + } + + execute(state: State) { + // Ensure the commits are ordered with the oldest first + state.commits.sort((a, b) => a.date.getTime() - b.date.getTime()); + runGitCommandInTerminal('cherry-pick', state.commits.map(c => c.sha).join(' '), state.repo.path); + } + + async *steps(): AsyncIterableIterator { + const state: Partial & { counter: number } = { counter: 0 }; + let oneRepo = false; + + while (true) { + try { + if (state.repo === undefined || state.counter < 1) { + const repos = [...(await Container.git.getOrderedRepositories())]; + + if (repos.length === 1) { + oneRepo = true; + state.counter++; + state.repo = repos[0]; + } + else { + const active = state.repo ? state.repo : await Container.git.getActiveRepository(); + + const step = this.createStep({ + title: this.title, + placeholder: 'Choose a repository', + items: await Promise.all( + repos.map(r => + RepositoryQuickPickItem.create(r, r.id === (active && active.id), { + branch: true, + fetched: true, + status: true + }) + ) + ) + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + break; + } + + state.repo = selection[0].item; + } + } + + state.destination = await state.repo.getBranch(); + if (state.destination === undefined) break; + + if (state.source === undefined || state.counter < 2) { + const destId = state.destination.id; + + const step = this.createStep({ + title: `${this.title} into ${state.destination.name}${Strings.pad(GlyphChars.Dot, 2, 2)}${ + state.repo.name + }`, + placeholder: 'Choose a branch or tag to cherry-pick from', + items: await this.getBranchesAndOrTags(state.repo, true, { + filterBranches: b => b.id !== destId + }) + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + if (oneRepo) { + break; + } + + continue; + } + + state.source = selection[0].item; + } + + if (state.commits === undefined || state.counter < 3) { + const log = await Container.git.getLog(state.source.repoPath, { + ref: `${state.destination.ref}..${state.source.ref}`, + merges: false + }); + + const step = this.createStep({ + title: `${this.title} onto ${state.destination.name}${Strings.pad(GlyphChars.Dot, 2, 2)}${ + state.repo.name + }`, + multiselect: log !== undefined, + placeholder: + log === undefined + ? `${state.source.name} has no pickable commits` + : `Choose commits to cherry-pick onto ${state.destination.name}`, + items: + log === undefined + ? [] + : [ + ...Iterables.map(log.commits.values(), commit => + CommitQuickPickItem.create( + commit, + state.commits ? state.commits.some(c => c.sha === commit.sha) : undefined, + { compact: true } + ) + ) + ] + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + continue; + } + + state.commits = selection.map(i => i.item); + } + + const step = this.createConfirmStep( + `Confirm ${this.title}${Strings.pad(GlyphChars.Dot, 2, 2)}${state.repo.name}`, + [ + { + label: this.title, + description: `${ + state.commits.length === 1 + ? state.commits[0].shortSha + : `${state.commits.length} commits` + } onto ${state.destination.name}`, + detail: `Will apply ${ + state.commits.length === 1 + ? `commit ${state.commits[0].shortSha}` + : `${state.commits.length} commits` + } onto ${state.destination.name}` + } + ] + ); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + continue; + } + + this.execute(state as State); + break; + } + catch (ex) { + if (ex instanceof CommandAbortError) break; + + throw ex; + } + } + } +} diff --git a/src/commands/quick/fetch.ts b/src/commands/quick/fetch.ts new file mode 100644 index 0000000..f0dacfe --- /dev/null +++ b/src/commands/quick/fetch.ts @@ -0,0 +1,125 @@ +'use strict'; +import { QuickPickItem } from 'vscode'; +import { Container } from '../../container'; +import { Repository } from '../../git/gitService'; +import { CommandAbortError, QuickCommandBase, QuickPickStep } from './quickCommand'; +import { RepositoryQuickPickItem } from '../../quickpicks'; +import { Strings } from '../../system'; +import { GlyphChars } from '../../constants'; + +interface State { + repos: Repository[]; + flags: string[]; +} + +export class FetchQuickCommand extends QuickCommandBase { + constructor() { + super('fetch', 'Fetch'); + } + + execute(state: State) { + return Container.git.fetchAll(state.repos, { + all: state.flags.includes('--all'), + prune: state.flags.includes('--prune') + }); + } + + async *steps(): AsyncIterableIterator { + const state: Partial & { counter: number } = { counter: 0 }; + let oneRepo = false; + + while (true) { + try { + if (state.repos === undefined || state.counter < 1) { + const repos = [...(await Container.git.getOrderedRepositories())]; + + if (repos.length === 1) { + oneRepo = true; + state.counter++; + state.repos = [repos[0]]; + } + else { + const step = this.createStep({ + multiselect: true, + title: this.title, + placeholder: 'Choose repositories', + items: await Promise.all( + repos.map(r => + RepositoryQuickPickItem.create(r, undefined, { + branch: true, + fetched: true, + status: true + }) + ) + ) + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + break; + } + + state.repos = selection.map(i => i.item); + } + } + + const step = this.createConfirmStep( + `Confirm ${this.title}${Strings.pad(GlyphChars.Dot, 2, 2)}${ + state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repositories` + }`, + [ + { + label: this.title, + description: '', + detail: `Will fetch ${ + state.repos.length === 1 + ? state.repos[0].formattedName + : `${state.repos.length} repositories` + }`, + item: [] + }, + { + label: `${this.title} & Prune`, + description: '--prune', + detail: `Will fetch and prune ${ + state.repos.length === 1 + ? state.repos[0].formattedName + : `${state.repos.length} repositories` + }`, + item: ['--prune'] + }, + { + label: `${this.title} All`, + description: '--all', + detail: `Will fetch all remotes of ${ + state.repos.length === 1 + ? state.repos[0].formattedName + : `${state.repos.length} repositories` + }`, + item: ['--all'] + } + ] + ); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + if (oneRepo) { + break; + } + + continue; + } + + state.flags = selection[0].item; + + this.execute(state as State); + break; + } + catch (ex) { + if (ex instanceof CommandAbortError) break; + + throw ex; + } + } + } +} diff --git a/src/commands/quick/gitCommand.ts b/src/commands/quick/gitCommand.ts new file mode 100644 index 0000000..19f4558 --- /dev/null +++ b/src/commands/quick/gitCommand.ts @@ -0,0 +1,88 @@ +'use strict'; +import { intersectionWith } from 'lodash'; +import { QuickCommandBase } from './quickCommand'; +import { GitBranch, GitTag, Repository } from '../../git/git'; +import { BranchQuickPickItem, TagQuickPickItem } from '../../quickpicks'; + +export abstract class GitCommandBase extends QuickCommandBase { + protected async getBranchesAndOrTags( + repos: Repository | Repository[], + includeTags: boolean, + { + filterBranches, + filterTags, + picked + }: { filterBranches?: (b: GitBranch) => boolean; filterTags?: (t: GitTag) => boolean; picked?: string } = {} + ) { + let branches: GitBranch[]; + let tags: GitTag[] | undefined; + + let singleRepo = false; + if (repos instanceof Repository || repos.length === 1) { + singleRepo = true; + const repo = repos instanceof Repository ? repos : repos[0]; + + [branches, tags] = await Promise.all([ + repo.getBranches({ filter: filterBranches, sort: true }), + includeTags ? repo.getTags({ filter: filterTags, includeRefs: true, sort: true }) : undefined + ]); + } + else { + const [branchesByRepo, tagsByRepo] = await Promise.all([ + Promise.all(repos.map(r => r.getBranches({ filter: filterBranches, sort: true }))), + includeTags + ? Promise.all(repos.map(r => r.getTags({ filter: filterTags, includeRefs: true, sort: true }))) + : undefined + ]); + + branches = GitBranch.sort( + intersectionWith(...branchesByRepo, ((b1: GitBranch, b2: GitBranch) => b1.name === b2.name) as any) + ); + + if (includeTags) { + tags = GitTag.sort( + intersectionWith(...tagsByRepo!, ((t1: GitTag, t2: GitTag) => t1.name === t2.name) as any) + ); + } + } + + if (!includeTags) { + return Promise.all( + branches.map(b => + BranchQuickPickItem.create(b, undefined, { + current: singleRepo ? 'checkmark' : false, + ref: singleRepo, + status: singleRepo, + type: 'remote' + }) + ) + ); + } + + return Promise.all([ + ...branches! + .filter(b => !b.remote) + .map(b => + BranchQuickPickItem.create(b, picked != null && b.ref === picked, { + current: singleRepo ? 'checkmark' : false, + ref: singleRepo, + status: singleRepo + }) + ), + ...tags!.map(t => + TagQuickPickItem.create(t, picked != null && t.ref === picked, { + ref: singleRepo, + type: true + }) + ), + ...branches! + .filter(b => b.remote) + .map(b => + BranchQuickPickItem.create(b, picked != null && b.ref === picked, { + current: singleRepo ? 'checkmark' : false, + type: 'remote' + }) + ) + ]); + } +} diff --git a/src/commands/quick/merge.ts b/src/commands/quick/merge.ts new file mode 100644 index 0000000..cb67cbe --- /dev/null +++ b/src/commands/quick/merge.ts @@ -0,0 +1,165 @@ +'use strict'; +import { QuickPickItem } from 'vscode'; +import { Container } from '../../container'; +import { GitBranch, Repository } from '../../git/gitService'; +import { GlyphChars } from '../../constants'; +import { CommandAbortError, QuickPickStep } from './quickCommand'; +import { BranchQuickPickItem, RepositoryQuickPickItem } from '../../quickpicks'; +import { Strings } from '../../system'; +import { GitCommandBase } from './gitCommand'; +import { runGitCommandInTerminal } from '../../terminal'; + +interface State { + repo: Repository; + destination: GitBranch; + source: GitBranch; + flags: string[]; +} + +export class MergeQuickCommand extends GitCommandBase { + constructor() { + super('merge', 'Merge'); + } + + execute(state: State) { + runGitCommandInTerminal('merge', [...state.flags, state.source.ref].join(' '), state.repo.path); + } + + async *steps(): AsyncIterableIterator { + const state: Partial & { counter: number } = { counter: 0 }; + let oneRepo = false; + + while (true) { + try { + if (state.repo === undefined || state.counter < 1) { + const repos = [...(await Container.git.getOrderedRepositories())]; + + if (repos.length === 1) { + oneRepo = true; + state.counter++; + state.repo = repos[0]; + } + else { + const active = state.repo ? state.repo : await Container.git.getActiveRepository(); + + const step = this.createStep({ + title: this.title, + placeholder: 'Choose a repository', + items: await Promise.all( + repos.map(r => + RepositoryQuickPickItem.create(r, r.id === (active && active.id), { + branch: true, + fetched: true, + status: true + }) + ) + ) + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + break; + } + + state.repo = selection[0].item; + } + } + + state.destination = await state.repo.getBranch(); + if (state.destination === undefined) break; + + if (state.source === undefined || state.counter < 2) { + const destId = state.destination.id; + + const step = this.createStep({ + title: `${this.title} into ${state.destination.name}${Strings.pad(GlyphChars.Dot, 2, 2)}${ + state.repo.name + }`, + placeholder: `Choose a branch or tag to merge into ${state.destination.name}`, + items: await this.getBranchesAndOrTags(state.repo, true, { + filterBranches: b => b.id !== destId, + picked: state.source && state.source.ref + }) + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + if (oneRepo) { + break; + } + continue; + } + + state.source = selection[0].item; + } + + const count = + (await Container.git.getCommitCount(state.repo.path, [ + `${state.destination.name}..${state.source.name}` + ])) || 0; + if (count === 0) { + const step = this.createConfirmStep( + `Confirm ${this.title}${Strings.pad(GlyphChars.Dot, 2, 2)}${state.repo.name}`, + [ + { + label: `Cancel ${this.title}`, + description: '', + detail: `${state.destination.name} is up to date with ${state.source.name}` + } + ], + false + ); + + yield step; + break; + } + + const step = this.createConfirmStep( + `Confirm ${this.title}${Strings.pad(GlyphChars.Dot, 2, 2)}${state.repo.name}`, + [ + { + label: this.title, + description: `${state.source.name} into ${state.destination.name}`, + detail: `Will merge ${Strings.pluralize('commit', count)} from ${state.source.name} into ${ + state.destination.name + }`, + item: [] + }, + { + label: `Fast-forward ${this.title}`, + description: `--ff-only ${state.source.name} into ${state.destination.name}`, + detail: `Will fast-forward merge ${Strings.pluralize('commit', count)} from ${ + state.source.name + } into ${state.destination.name}`, + item: ['--ff-only'] + }, + { + label: `No Fast-forward ${this.title}`, + description: `--no-ff ${state.source.name} into ${state.destination.name}`, + detail: `Will create a merge commit when merging ${Strings.pluralize( + 'commit', + count + )} from ${state.source.name} into ${state.destination.name}`, + item: ['--no-ff'] + } + ] + ); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + continue; + } + + state.flags = selection[0].item; + + this.execute(state as State); + break; + } + catch (ex) { + if (ex instanceof CommandAbortError) break; + + throw ex; + } + } + } +} diff --git a/src/commands/quick/pull.ts b/src/commands/quick/pull.ts new file mode 100644 index 0000000..7708c23 --- /dev/null +++ b/src/commands/quick/pull.ts @@ -0,0 +1,112 @@ +'use strict'; +import { QuickPickItem } from 'vscode'; +import { Container } from '../../container'; +import { Repository } from '../../git/gitService'; +import { CommandAbortError, QuickCommandBase, QuickPickStep } from './quickCommand'; +import { RepositoryQuickPickItem } from '../../quickpicks'; +import { Strings } from '../../system'; +import { GlyphChars } from '../../constants'; + +interface State { + repos: Repository[]; + flags: string[]; +} + +export class PullQuickCommand extends QuickCommandBase { + constructor() { + super('pull', 'Pull'); + } + + execute(state: State) { + return Container.git.pullAll(state.repos, { rebase: state.flags.includes('--rebase') }); + } + + async *steps(): AsyncIterableIterator { + const state: Partial & { counter: number } = { counter: 0 }; + let oneRepo = false; + + while (true) { + try { + if (state.repos === undefined || state.counter < 1) { + const repos = [...(await Container.git.getOrderedRepositories())]; + + if (repos.length === 1) { + oneRepo = true; + state.counter++; + state.repos = [repos[0]]; + } + else { + const step = this.createStep({ + multiselect: true, + title: this.title, + placeholder: 'Choose repositories', + items: await Promise.all( + repos.map(r => + RepositoryQuickPickItem.create(r, undefined, { + branch: true, + fetched: true, + status: true + }) + ) + ) + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + break; + } + + state.repos = selection.map(i => i.item); + } + } + + const step = this.createConfirmStep( + `Confirm ${this.title}${Strings.pad(GlyphChars.Dot, 2, 2)}${ + state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repositories` + }`, + [ + { + label: this.title, + description: '', + detail: `Will pull ${ + state.repos.length === 1 + ? state.repos[0].formattedName + : `${state.repos.length} repositories` + }`, + item: [] + }, + { + label: `${this.title} with Rebase`, + description: '--rebase', + detail: `Will pull with rebase ${ + state.repos.length === 1 + ? state.repos[0].formattedName + : `${state.repos.length} repositories` + }`, + item: ['--rebase'] + } + ] + ); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + if (oneRepo) { + break; + } + + continue; + } + + state.flags = selection[0].item; + + this.execute(state as State); + break; + } + catch (ex) { + if (ex instanceof CommandAbortError) break; + + throw ex; + } + } + } +} diff --git a/src/commands/quick/push.ts b/src/commands/quick/push.ts new file mode 100644 index 0000000..3edec30 --- /dev/null +++ b/src/commands/quick/push.ts @@ -0,0 +1,97 @@ +'use strict'; +import { Container } from '../../container'; +import { Repository } from '../../git/gitService'; +import { CommandAbortError, QuickCommandBase, QuickPickStep } from './quickCommand'; +import { RepositoryQuickPickItem } from '../../quickpicks'; +import { Strings } from '../../system'; +import { GlyphChars } from '../../constants'; + +interface State { + repos: Repository[]; +} + +export class PushQuickCommand extends QuickCommandBase { + constructor() { + super('push', 'Push'); + } + + execute(state: State) { + return Container.git.pushAll(state.repos); + } + + async *steps(): AsyncIterableIterator { + const state: Partial & { counter: number } = { counter: 0 }; + let oneRepo = false; + + while (true) { + try { + if (state.repos === undefined || state.counter < 1) { + const repos = [...(await Container.git.getOrderedRepositories())]; + + if (repos.length === 1) { + oneRepo = true; + state.counter++; + state.repos = [repos[0]]; + } + else { + const step = this.createStep({ + multiselect: true, + title: this.title, + placeholder: 'Choose repositories', + items: await Promise.all( + repos.map(r => + RepositoryQuickPickItem.create(r, undefined, { + branch: true, + fetched: true, + status: true + }) + ) + ) + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + break; + } + + state.repos = selection.map(i => i.item); + } + } + + const step = this.createConfirmStep( + `Confirm ${this.title}${Strings.pad(GlyphChars.Dot, 2, 2)}${ + state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repositories` + }`, + [ + { + label: this.title, + description: '', + detail: `Will push ${ + state.repos.length === 1 + ? state.repos[0].formattedName + : `${state.repos.length} repositories` + }` + } + ] + ); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + if (oneRepo) { + break; + } + + continue; + } + + this.execute(state as State); + break; + } + catch (ex) { + if (ex instanceof CommandAbortError) break; + + throw ex; + } + } + } +} diff --git a/src/commands/quick/quickCommand.ts b/src/commands/quick/quickCommand.ts new file mode 100644 index 0000000..9a4a352 --- /dev/null +++ b/src/commands/quick/quickCommand.ts @@ -0,0 +1,113 @@ +'use strict'; +import { QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; + +export interface QuickPickStep { + buttons?: QuickInputButton[]; + selectedItems?: QuickPickItem[]; + items: QuickPickItem[]; + multiselect?: boolean; + placeholder?: string; + title?: string; + + onDidClickButton?(quickpick: QuickPick, button: QuickInputButton): void; + validate?(selection: T[]): boolean; +} + +export class CommandAbortError extends Error { + constructor() { + super('Abort'); + } +} + +export abstract class QuickCommandBase implements QuickPickItem { + static is(item: QuickPickItem): item is QuickCommandBase { + return item instanceof QuickCommandBase; + } + + readonly description?: string; + readonly detail?: string; + + private _current: QuickPickStep | undefined; + private _stepsIterator: AsyncIterableIterator | undefined; + + constructor( + public readonly label: string, + public readonly title: string, + options: { + description?: string; + detail?: string; + } = {} + ) { + this.description = options.description; + this.detail = options.detail; + } + + abstract steps(): AsyncIterableIterator; + + async previous(): Promise { + // Simulate going back, by having no selection + return (await this.next([])).value; + } + + async next(selection?: QuickPickItem[]): Promise> { + if (this._stepsIterator === undefined) { + this._stepsIterator = this.steps(); + } + + const result = await this._stepsIterator.next(selection); + this._current = result.value; + + if (result.done) { + this._stepsIterator = undefined; + } + + return result; + } + + get value(): QuickPickStep | undefined { + return this._current; + } + + protected createConfirmStep( + title: string, + confirmations: T[], + cancellable: boolean = true + ): QuickPickStep { + return this.createStep({ + placeholder: `Confirm ${this.title}`, + title: title, + items: cancellable ? [...confirmations, { label: 'Cancel' }] : confirmations, + selectedItems: [confirmations[0]], + // eslint-disable-next-line no-loop-func + validate: (selection: T[]) => { + if (selection[0].label === 'Cancel') throw new CommandAbortError(); + return true; + } + }); + } + + protected createStep(step: QuickPickStep): QuickPickStep { + return step; + } + + protected canMoveNext( + step: QuickPickStep, + state: { counter: number }, + selection: T[] | undefined + ): selection is T[] { + if (selection === undefined || selection.length === 0) { + state.counter--; + if (state.counter < 0) { + state.counter = 0; + } + return false; + } + + if (step.validate === undefined || step.validate(selection)) { + state.counter++; + return true; + } + + return false; + } +} diff --git a/src/commands/quick/rebase.ts b/src/commands/quick/rebase.ts new file mode 100644 index 0000000..0db0894 --- /dev/null +++ b/src/commands/quick/rebase.ts @@ -0,0 +1,157 @@ +'use strict'; +import { QuickPickItem } from 'vscode'; +import { Container } from '../../container'; +import { GitBranch, Repository } from '../../git/gitService'; +import { GlyphChars } from '../../constants'; +import { CommandAbortError, QuickPickStep } from './quickCommand'; +import { BranchQuickPickItem, RepositoryQuickPickItem } from '../../quickpicks'; +import { Strings } from '../../system'; +import { GitCommandBase } from './gitCommand'; +import { runGitCommandInTerminal } from '../../terminal'; + +interface State { + repo: Repository; + destination: GitBranch; + source: GitBranch; + flags: string[]; +} + +export class RebaseQuickCommand extends GitCommandBase { + constructor() { + super('rebase', 'Rebase'); + } + + execute(state: State) { + runGitCommandInTerminal('rebase', [...state.flags, state.source.ref].join(' '), state.repo.path); + } + + async *steps(): AsyncIterableIterator { + const state: Partial & { counter: number } = { counter: 0 }; + let oneRepo = false; + + while (true) { + try { + if (state.repo === undefined || state.counter < 1) { + const repos = [...(await Container.git.getOrderedRepositories())]; + + if (repos.length === 1) { + oneRepo = true; + state.counter++; + state.repo = repos[0]; + } + else { + const active = state.repo ? state.repo : await Container.git.getActiveRepository(); + + const step = this.createStep({ + title: this.title, + placeholder: 'Choose a repository', + items: await Promise.all( + repos.map(r => + RepositoryQuickPickItem.create(r, r.id === (active && active.id), { + branch: true, + fetched: true, + status: true + }) + ) + ) + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + break; + } + + state.repo = selection[0].item; + } + } + + state.destination = await state.repo.getBranch(); + if (state.destination === undefined) break; + + if (state.source === undefined || state.counter < 2) { + const destId = state.destination.id; + + const step = this.createStep({ + title: `${this.title} ${state.destination.name}${Strings.pad(GlyphChars.Dot, 2, 2)}${ + state.repo.name + }`, + placeholder: `Choose a branch or tag to rebase ${state.destination.name} with`, + items: await this.getBranchesAndOrTags(state.repo, true, { + filterBranches: b => b.id !== destId, + picked: state.source && state.source.ref + }) + }); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + if (oneRepo) { + break; + } + continue; + } + + state.source = selection[0].item; + } + + const count = + (await Container.git.getCommitCount(state.repo.path, [ + `${state.destination.name}..${state.source.name}` + ])) || 0; + if (count === 0) { + const step = this.createConfirmStep( + `Confirm ${this.title}${Strings.pad(GlyphChars.Dot, 2, 2)}${state.repo.name}`, + [ + { + label: `Cancel ${this.title}`, + description: '', + detail: `${state.destination.name} is up to date with ${state.source.name}` + } + ], + false + ); + + yield step; + break; + } + + const step = this.createConfirmStep( + `Confirm ${this.title}${Strings.pad(GlyphChars.Dot, 2, 2)}${state.repo.name}`, + [ + { + label: this.title, + description: `${state.destination.name} with ${state.source.name}`, + detail: `Will update ${state.destination.name} by applying ${Strings.pluralize( + 'commit', + count + )} on top of ${state.source.name}`, + item: [] + }, + { + label: `Interactive ${this.title}`, + description: `--interactive ${state.destination.name} with ${state.source.name}`, + detail: `Will interactively update ${ + state.destination.name + } by applying ${Strings.pluralize('commit', count)} on top of ${state.source.name}`, + item: ['--interactive'] + } + ] + ); + const selection = yield step; + + if (!this.canMoveNext(step, state, selection)) { + continue; + } + + state.flags = selection[0].item; + + this.execute(state as State); + break; + } + catch (ex) { + if (ex instanceof CommandAbortError) break; + + throw ex; + } + } + } +} diff --git a/src/git/git.ts b/src/git/git.ts index f5c87cd..c2f7d2a 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -608,8 +608,12 @@ export class Git { return git({ cwd: repoPath }, ...params); } - static fetch(repoPath: string, options: { all?: boolean; remote?: string } = {}) { + static fetch(repoPath: string, options: { all?: boolean; prune?: boolean; remote?: string } = {}) { const params = ['fetch']; + if (options.prune) { + params.push('--prune'); + } + if (options.remote) { params.push(options.remote); } @@ -635,22 +639,26 @@ export class Git { { authors, maxCount, + merges, reverse, similarityThreshold - }: { authors?: string[]; maxCount?: number; reverse?: boolean; similarityThreshold?: number } + }: { authors?: string[]; maxCount?: number; merges?: boolean; reverse?: boolean; similarityThreshold?: number } ) { const params = [ 'log', '--name-status', `--format=${GitLogParser.defaultFormat}`, '--full-history', - `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`, - '-m' + `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}` ]; if (maxCount && !reverse) { params.push(`-n${maxCount}`); } + if (merges) { + params.push('-m'); + } + if (authors) { params.push('--use-mailmap', ...authors.map(a => `--author=${a}`)); } @@ -858,6 +866,21 @@ export class Git { return git({ cwd: repoPath }, 'reset', '-q', '--', fileName); } + static async rev_list( + repoPath: string, + refs: string[], + options: { count?: boolean } = {} + ): Promise { + const params = []; + if (options.count) { + params.push('--count'); + } + params.push(...refs); + + const data = await git({ cwd: repoPath, errors: GitErrorHandling.Ignore }, 'rev-list', ...params); + return data.length === 0 ? undefined : Number(data.trim()) || undefined; + } + static async rev_parse(repoPath: string, ref: string): Promise { const data = await git({ cwd: repoPath, errors: GitErrorHandling.Ignore }, 'rev-parse', ref); return data.length === 0 ? undefined : data.trim(); diff --git a/src/git/gitService.ts b/src/git/gitService.ts index 525f8a1..ff8b35c 100644 --- a/src/git/gitService.ts +++ b/src/git/gitService.ts @@ -520,8 +520,8 @@ export class GitService implements Disposable { @gate() @log() - fetch(repoPath: string, remote?: string) { - return Git.fetch(repoPath, { remote: remote }); + fetch(repoPath: string, options: { all?: boolean; prune?: boolean; remote?: string } = {}) { + return Git.fetch(repoPath, options); } @gate() @@ -530,14 +530,14 @@ export class GitService implements Disposable { 0: (repos?: Repository[]) => (repos === undefined ? false : repos.map(r => r.name).join(', ')) } }) - async fetchAll(repositories?: Repository[]) { + async fetchAll(repositories?: Repository[], options: { all?: boolean; prune?: boolean } = {}) { if (repositories === undefined) { repositories = await this.getOrderedRepositories(); } if (repositories.length === 0) return; if (repositories.length === 1) { - repositories[0].fetch(); + repositories[0].fetch(options); return; } @@ -547,7 +547,7 @@ export class GitService implements Disposable { location: ProgressLocation.Notification, title: `Fetching ${repositories.length} repositories` }, - () => Promise.all(repositories!.map(r => r.fetch({ progress: false }))) + () => Promise.all(repositories!.map(r => r.fetch({ progress: false, ...options }))) ); } @@ -557,14 +557,14 @@ export class GitService implements Disposable { 0: (repos?: Repository[]) => (repos === undefined ? false : repos.map(r => r.name).join(', ')) } }) - async pullAll(repositories?: Repository[]) { + async pullAll(repositories?: Repository[], options: { rebase?: boolean } = {}) { if (repositories === undefined) { repositories = await this.getOrderedRepositories(); } if (repositories.length === 0) return; if (repositories.length === 1) { - repositories[0].pull(); + repositories[0].pull(options); return; } @@ -574,7 +574,7 @@ export class GitService implements Disposable { location: ProgressLocation.Notification, title: `Pulling ${repositories.length} repositories` }, - () => Promise.all(repositories!.map(r => r.pull({ progress: false }))) + () => Promise.all(repositories!.map(r => r.pull({ progress: false, ...options }))) ); } @@ -611,6 +611,19 @@ export class GitService implements Disposable { editor !== undefined ? `TextEditor(${Logger.toLoggable(editor.document.uri)})` : 'undefined' } }) + async getActiveRepository(editor?: TextEditor): Promise { + const repoPath = await this.getActiveRepoPath(editor); + if (repoPath === undefined) return undefined; + + return this.getRepository(repoPath); + } + + @log({ + args: { + 0: (editor: TextEditor) => + editor !== undefined ? `TextEditor(${Logger.toLoggable(editor.document.uri)})` : 'undefined' + } + }) async getActiveRepoPath(editor?: TextEditor): Promise { editor = editor || window.activeTextEditor; @@ -1112,6 +1125,11 @@ export class GitService implements Disposable { } @log() + getCommitCount(repoPath: string, refs: string[]) { + return Git.rev_list(repoPath, refs, { count: true }); + } + + @log() async getCommitForFile( repoPath: string | undefined, fileName: string, @@ -1357,7 +1375,10 @@ export class GitService implements Disposable { @log() async getLog( repoPath: string, - { ref, ...options }: { authors?: string[]; maxCount?: number; ref?: string; reverse?: boolean } = {} + { + ref, + ...options + }: { authors?: string[]; maxCount?: number; merges?: boolean; ref?: string; reverse?: boolean } = {} ): Promise { const maxCount = options.maxCount == null ? Container.config.advanced.maxListItems || 0 : options.maxCount; @@ -1365,6 +1386,7 @@ export class GitService implements Disposable { const data = await Git.log(repoPath, ref, { authors: options.authors, maxCount: maxCount, + merges: options.merges === undefined ? true : options.merges, reverse: options.reverse, similarityThreshold: Container.config.advanced.similarityThreshold }); diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index 296597d..0d7118a 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -233,6 +233,34 @@ export class Repository implements Disposable { } } + @gate() + @log() + async checkout(ref: string, options: { progress?: boolean } = {}) { + const { progress } = { progress: true, ...options }; + if (!progress) return this.checkoutCore(ref); + + return void (await window.withProgress( + { + location: ProgressLocation.Notification, + title: `Checking out ${this.formattedName} to ${ref}...`, + cancellable: false + }, + () => this.checkoutCore(ref) + )); + } + + private async checkoutCore(ref: string, options: { remote?: string } = {}) { + try { + void (await Container.git.checkout(this.path, ref)); + + this.fireChange(RepositoryChange.Repository); + } + catch (ex) { + Logger.error(ex); + Messages.showGenericErrorMessage('Unable to checkout repository'); + } + } + containsUri(uri: Uri) { if (GitUri.is(uri)) { uri = uri.repoPath !== undefined ? GitUri.file(uri.repoPath) : uri.documentUri(); @@ -243,7 +271,7 @@ export class Repository implements Disposable { @gate() @log() - async fetch(options: { progress?: boolean; remote?: string } = {}) { + async fetch(options: { all?: boolean; progress?: boolean; prune?: boolean; remote?: string } = {}) { const { progress, ...opts } = { progress: true, ...options }; if (!progress) return this.fetchCore(opts); @@ -256,9 +284,9 @@ export class Repository implements Disposable { )); } - private async fetchCore(options: { remote?: string } = {}) { + private async fetchCore(options: { all?: boolean; prune?: boolean; remote?: string } = {}) { try { - void (await Container.git.fetch(this.path, options.remote)); + void (await Container.git.fetch(this.path, options)); this.fireChange(RepositoryChange.Repository); } @@ -279,6 +307,17 @@ export class Repository implements Disposable { return Container.git.getBranches(this.path, options); } + getBranchesAndOrTags( + options: { + filterBranches?: (b: GitBranch) => boolean; + filterTags?: (t: GitTag) => boolean; + include?: 'all' | 'branches' | 'tags'; + sort?: boolean; + } = {} + ) { + return Container.git.getBranchesAndOrTags(this.path, options); + } + getChangedFilesCount(sha?: string): Promise { return Container.git.getChangedFilesCount(this.path, sha); } @@ -337,8 +376,8 @@ export class Repository implements Disposable { @gate() @log() - async pull(options: { progress?: boolean } = {}) { - const { progress } = { progress: true, ...options }; + async pull(options: { progress?: boolean; rebase?: boolean } = {}) { + const { progress, ...opts } = { progress: true, ...options }; if (!progress) return this.pullCore(); return void (await window.withProgress( @@ -346,15 +385,15 @@ export class Repository implements Disposable { location: ProgressLocation.Notification, title: `Pulling ${this.formattedName}...` }, - () => this.pullCore() + () => this.pullCore(opts) )); } - private async pullCore() { + private async pullCore(options: { rebase?: boolean } = {}) { try { const tracking = await this.hasTrackingBranch(); if (tracking) { - void (await commands.executeCommand('git.pull', this.path)); + void (await commands.executeCommand(options.rebase ? 'git.pullRebase' : 'git.pull', this.path)); } else if (configuration.getAny('git.fetchOnPull', Uri.file(this.path))) { void (await Container.git.fetch(this.path)); diff --git a/src/system/string.ts b/src/system/string.ts index b7ae1f2..5722fb3 100644 --- a/src/system/string.ts +++ b/src/system/string.ts @@ -198,6 +198,13 @@ export namespace Strings { .digest(encoding); } + export function splitLast(s: string, splitter: string) { + const index = s.lastIndexOf(splitter); + if (index === -1) return [s]; + + return [s.substr(index), s.substring(0, index - 1)]; + } + export function splitSingle(s: string, splitter: string) { const parts = s.split(splitter, 1); const first = parts[0]; diff --git a/src/views/nodes/repositoryNode.ts b/src/views/nodes/repositoryNode.ts index 09fd262..e2c90de 100644 --- a/src/views/nodes/repositoryNode.ts +++ b/src/views/nodes/repositoryNode.ts @@ -190,12 +190,12 @@ export class RepositoryNode extends SubscribeableViewNode { } @log() - fetch(options: { progress?: boolean; remote?: string } = {}) { + fetch(options: { all?: boolean; progress?: boolean; prune?: boolean; remote?: string } = {}) { return this.repo.fetch(options); } @log() - pull(options: { progress?: boolean } = {}) { + pull(options: { progress?: boolean; rebase?: boolean } = {}) { return this.repo.pull(options); } diff --git a/src/vsls/host.ts b/src/vsls/host.ts index 88a0232..d69628f 100644 --- a/src/vsls/host.ts +++ b/src/vsls/host.ts @@ -32,6 +32,7 @@ const gitWhitelist = new Map boolean>([ ['ls-tree', defaultWhitelistFn], ['merge-base', defaultWhitelistFn], ['remote', args => args[1] === '-v' || args[1] === 'get-url'], + ['rev-list', defaultWhitelistFn], ['rev-parse', defaultWhitelistFn], ['shortlog', defaultWhitelistFn], ['show', defaultWhitelistFn],