diff --git a/src/commands/openBranchInRemote.ts b/src/commands/openBranchInRemote.ts index 3d65fb8..16d05ec 100644 --- a/src/commands/openBranchInRemote.ts +++ b/src/commands/openBranchInRemote.ts @@ -1,5 +1,4 @@ 'use strict'; -import { Arrays } from '../system'; import { commands, TextEditor, Uri, window } from 'vscode'; import { ActiveEditorCommand, CommandContext, Commands, getCommandUri, isCommandViewContextWithBranch } from './common'; import { GlyphChars } from '../constants'; @@ -52,7 +51,8 @@ export class OpenBranchInRemoteCommand extends ActiveEditorCommand { if (args.branch === undefined) return undefined; } - const remotes = Arrays.uniqueBy(await this.git.getRemotes(repoPath), _ => _.url, _ => !!_.provider); + const remotes = (await this.git.getRemotes(repoPath)).filter(r => r.provider !== undefined); + return commands.executeCommand(Commands.OpenInRemote, uri, { resource: { type: 'branch', diff --git a/src/commands/openBranchesInRemote.ts b/src/commands/openBranchesInRemote.ts index e79df83..3db97e4 100644 --- a/src/commands/openBranchesInRemote.ts +++ b/src/commands/openBranchesInRemote.ts @@ -1,5 +1,4 @@ 'use strict'; -import { Arrays } from '../system'; import { commands, TextEditor, Uri, window } from 'vscode'; import { ActiveEditorCommand, CommandContext, Commands, getCommandUri, isCommandViewContextWithRemote } from './common'; import { GitService, GitUri } from '../gitService'; @@ -34,7 +33,7 @@ export class OpenBranchesInRemoteCommand extends ActiveEditorCommand { if (!repoPath) return undefined; try { - const remotes = Arrays.uniqueBy(await this.git.getRemotes(repoPath), r => r.url, r => !!r.provider); + const remotes = (await this.git.getRemotes(repoPath)).filter(r => r.provider !== undefined); return commands.executeCommand(Commands.OpenInRemote, uri, { resource: { diff --git a/src/commands/openCommitInRemote.ts b/src/commands/openCommitInRemote.ts index efae8b9..d52ad17 100644 --- a/src/commands/openCommitInRemote.ts +++ b/src/commands/openCommitInRemote.ts @@ -1,5 +1,4 @@ 'use strict'; -import { Arrays } from '../system'; import { commands, TextEditor, Uri, window } from 'vscode'; import { ActiveEditorCommand, CommandContext, Commands, getCommandUri, isCommandViewContextWithCommit } from './common'; import { GitBlameCommit, GitService, GitUri } from '../gitService'; @@ -53,7 +52,8 @@ export class OpenCommitInRemoteCommand extends ActiveEditorCommand { args.sha = commit.sha; } - const remotes = Arrays.uniqueBy(await this.git.getRemotes(gitUri.repoPath), _ => _.url, _ => !!_.provider); + const remotes = (await this.git.getRemotes(gitUri.repoPath)).filter(r => r.provider !== undefined); + return commands.executeCommand(Commands.OpenInRemote, uri, { resource: { type: 'commit', diff --git a/src/commands/openFileInRemote.ts b/src/commands/openFileInRemote.ts index 9893d84..36f46f5 100644 --- a/src/commands/openFileInRemote.ts +++ b/src/commands/openFileInRemote.ts @@ -1,5 +1,4 @@ 'use strict'; -import { Arrays } from '../system'; import { commands, Range, TextEditor, Uri, window } from 'vscode'; import { ActiveEditorCommand, CommandContext, Commands, getCommandUri, isCommandViewContextWithBranch, isCommandViewContextWithCommit } from './common'; import { GitService, GitUri } from '../gitService'; @@ -45,7 +44,7 @@ export class OpenFileInRemoteCommand extends ActiveEditorCommand { } try { - const remotes = Arrays.uniqueBy(await this.git.getRemotes(gitUri.repoPath), _ => _.url, _ => !!_.provider); + const remotes = (await this.git.getRemotes(gitUri.repoPath)).filter(r => r.provider !== undefined); const range = (args.range && editor !== undefined) ? new Range(editor.selection.start.with({ line: editor.selection.start.line + 1 }), editor.selection.end.with({ line: editor.selection.end.line + 1 })) : undefined; diff --git a/src/commands/openRepoInRemote.ts b/src/commands/openRepoInRemote.ts index bf4828f..7dc43fc 100644 --- a/src/commands/openRepoInRemote.ts +++ b/src/commands/openRepoInRemote.ts @@ -1,5 +1,4 @@ 'use strict'; -import { Arrays } from '../system'; import { commands, TextEditor, Uri, window } from 'vscode'; import { ActiveEditorCommand, CommandContext, Commands, getCommandUri, isCommandViewContextWithRemote } from './common'; import { GitService, GitUri } from '../gitService'; @@ -34,7 +33,7 @@ export class OpenRepoInRemoteCommand extends ActiveEditorCommand { if (!repoPath) return undefined; try { - const remotes = Arrays.uniqueBy(await this.git.getRemotes(repoPath), r => r.url, r => !!r.provider); + const remotes = (await this.git.getRemotes(repoPath)).filter(r => r.provider !== undefined); return commands.executeCommand(Commands.OpenInRemote, uri, { resource: { diff --git a/src/git/git.ts b/src/git/git.ts index 9ba025b..15b578b 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -14,6 +14,7 @@ export * from './parsers/blameParser'; export * from './parsers/branchParser'; export * from './parsers/diffParser'; export * from './parsers/logParser'; +export * from './parsers/remoteParser'; export * from './parsers/stashParser'; export * from './parsers/statusParser'; export * from './remotes/provider'; diff --git a/src/git/gitContextTracker.ts b/src/git/gitContextTracker.ts index 9919f53..eba4d71 100644 --- a/src/git/gitContextTracker.ts +++ b/src/git/gitContextTracker.ts @@ -2,7 +2,7 @@ import { Disposable, Event, EventEmitter, TextDocument, TextDocumentChangeEvent, TextEditor, window, workspace } from 'vscode'; import { TextDocumentComparer } from '../comparers'; import { CommandContext, setCommandContext } from '../constants'; -import { GitService, GitUri } from '../gitService'; +import { GitService, GitUri, RepoChangedReasons } from '../gitService'; import { Logger } from '../logger'; export interface BlameabilityChangeEvent { @@ -32,6 +32,7 @@ export class GitContextTracker extends Disposable { subscriptions.push(workspace.onDidChangeConfiguration(this._onConfigurationChanged, this)); subscriptions.push(workspace.onDidSaveTextDocument(this._onTextDocumentSaved, this)); subscriptions.push(this.git.onDidBlameFail(this._onBlameFailed, this)); + subscriptions.push(this.git.onDidChangeRepo(this._onRepoChanged, this)); this._disposable = Disposable.from(...subscriptions); @@ -54,6 +55,13 @@ export class GitContextTracker extends Disposable { } } + async _onRepoChanged(reasons: RepoChangedReasons[]) { + if (!reasons.includes(RepoChangedReasons.Remotes)) return; + + const gitUri = this._editor === undefined ? undefined : await GitUri.fromUri(this._editor.document.uri, this.git); + this._updateContextHasRemotes(gitUri); + } + private _onActiveTextEditorChanged(editor: TextEditor | undefined) { this._editor = editor; this._updateContext(this._gitEnabled ? editor : undefined); diff --git a/src/git/models/remote.ts b/src/git/models/remote.ts index dfd2bea..6a00fd5 100644 --- a/src/git/models/remote.ts +++ b/src/git/models/remote.ts @@ -5,23 +5,9 @@ export type GitRemoteType = 'fetch' | 'push'; export class GitRemote { - name: string; - url: string; - type: GitRemoteType; - provider?: RemoteProvider; - constructor(remote: string) { - remote = remote.trim(); - - const [name, info] = remote.split('\t'); - this.name = name; - - const [url, typeInfo] = info.split(' '); - this.url = url; - - this.type = typeInfo.substring(1, typeInfo.length - 1) as GitRemoteType; - - this.provider = RemoteProviderFactory.getRemoteProvider(this.url); + constructor(public readonly repoPath: string, public readonly name: string, public readonly url: string, public readonly domain: string, public readonly path: string, public readonly types: GitRemoteType[]) { + this.provider = RemoteProviderFactory.getRemoteProvider(this.domain, this.path); } } \ No newline at end of file diff --git a/src/git/parsers/remoteParser.ts b/src/git/parsers/remoteParser.ts new file mode 100644 index 0000000..2b91da8 --- /dev/null +++ b/src/git/parsers/remoteParser.ts @@ -0,0 +1,50 @@ +'use strict'; +import { GitRemote } from './../git'; +import { GitRemoteType } from '../models/remote'; + +const remoteRegex = /^(.*)\t(.*)\s\((.*)\)$/gm; +const urlRegex = /^(?:git:\/\/(.*?)\/|https:\/\/(.*?)\/|http:\/\/(.*?)\/|git@(.*):|ssh:\/\/(?:.*@)?(.*?)(?::.*?)?\/)(.*)$/; + +export class GitRemoteParser { + + static parse(data: string, repoPath: string): GitRemote[] { + if (!data) return []; + + const remotes: GitRemote[] = []; + const groups = Object.create(null); + + let match: RegExpExecArray | null = null; + do { + match = remoteRegex.exec(data); + if (match == null) break; + + const url = match[2]; + + const [domain, path] = this.parseGitUrl(url); + + let remote: GitRemote | undefined = groups[url]; + if (remote === undefined) { + remote = new GitRemote(repoPath, match[1], url, domain, path, [match[3] as GitRemoteType]); + remotes.push(remote); + groups[url] = remote; + } + else { + remote.types.push(match[3] as GitRemoteType); + } + } while (match != null); + + if (!remotes.length) return []; + + return remotes; + } + + static parseGitUrl(url: string): [string, string] { + const match = urlRegex.exec(url); + if (match == null) return ['', '']; + + return [ + match[1] || match[2] || match[3] || match[4] || match[5], + match[6].replace(/\.git\/?$/, '') + ]; + } +} \ No newline at end of file diff --git a/src/git/remotes/factory.ts b/src/git/remotes/factory.ts index 931b398..083d701 100644 --- a/src/git/remotes/factory.ts +++ b/src/git/remotes/factory.ts @@ -1,5 +1,6 @@ 'use strict'; -import { ExtensionContext, workspace } from 'vscode'; +import { Objects } from '../../system'; +import { Event, EventEmitter, ExtensionContext, workspace } from 'vscode'; import { BitbucketService } from './bitbucket'; import { BitbucketServerService } from './bitbucket-server'; import { CustomRemoteType, IConfig, IRemotesConfig } from '../../configuration'; @@ -9,22 +10,9 @@ import { GitLabService } from './gitlab'; import { Logger } from '../../logger'; import { RemoteProvider } from './provider'; import { VisualStudioService } from './visualStudio'; -import { Objects } from '../../system'; export { RemoteProvider }; -const UrlRegex = /^(?:git:\/\/(.*?)\/|https:\/\/(.*?)\/|http:\/\/(.*?)\/|git@(.*):|ssh:\/\/(?:.*@)?(.*?)(?::.*?)?\/)(.*)$/; - -function getCustomProvider(type: CustomRemoteType) { - switch (type) { - case CustomRemoteType.Bitbucket: return (domain: string, path: string) => new BitbucketService(domain, path, true); - case CustomRemoteType.BitbucketServer: return (domain: string, path: string) => new BitbucketServerService(domain, path, true); - case CustomRemoteType.GitHub: return (domain: string, path: string) => new GitHubService(domain, path, true); - case CustomRemoteType.GitLab: return (domain: string, path: string) => new GitLabService(domain, path, true); - } - return undefined; -} - const defaultProviderMap = new Map RemoteProvider>([ ['bitbucket.org', (domain: string, path: string) => new BitbucketService(domain, path)], ['github.com', (domain: string, path: string) => new GitHubService(domain, path)], @@ -32,48 +20,29 @@ const defaultProviderMap = new Map Rem ['visualstudio.com', (domain: string, path: string) => new VisualStudioService(domain, path)] ]); -let providerMap: Map RemoteProvider>; -let remotesCfg: IRemotesConfig[]; - -function onConfigurationChanged() { - const cfg = workspace.getConfiguration().get(ExtensionKey); - if (cfg === undefined) return; - - if (!Objects.areEquivalent(cfg.remotes, remotesCfg)) { - providerMap = new Map(defaultProviderMap); - - remotesCfg = cfg.remotes; - if (remotesCfg != null && remotesCfg.length > 0) { - for (const svc of remotesCfg) { - const provider = getCustomProvider(svc.type); - if (provider === undefined) continue; - - providerMap.set(svc.domain.toLowerCase(), provider); - } - } - } -} - export class RemoteProviderFactory { - static configure(context: ExtensionContext) { - context.subscriptions.push(workspace.onDidChangeConfiguration(onConfigurationChanged)); - onConfigurationChanged(); + private static _providerMap: Map RemoteProvider>; + private static _remotesCfg: IRemotesConfig[]; + + private static _onDidChange = new EventEmitter(); + public static get onDidChange(): Event { + return this._onDidChange.event; } - static getRemoteProvider(url: string): RemoteProvider | undefined { + static configure(context: ExtensionContext) { + context.subscriptions.push(workspace.onDidChangeConfiguration(() => this.onConfigurationChanged())); + this.onConfigurationChanged(true); + } + + static getRemoteProvider(domain: string, path: string): RemoteProvider | undefined { try { - const match = UrlRegex.exec(url); - if (match == null) return undefined; + let key = domain.toLowerCase(); + if (key.endsWith('visualstudio.com')) { + key = 'visualstudio.com'; + } - const domain = match[1] || match[2] || match[3] || match[4] || match[5]; - const path = match[6].replace(/\.git\/?$/, ''); - - const key = domain.toLowerCase().endsWith('visualstudio.com') - ? 'visualstudio.com' - : domain; - - const creator = providerMap.get(key.toLowerCase()); + const creator = this._providerMap.get(key); if (creator === undefined) return undefined; return creator(domain, path); @@ -83,4 +52,37 @@ export class RemoteProviderFactory { return undefined; } } + + private static onConfigurationChanged(silent: boolean = false) { + const cfg = workspace.getConfiguration().get(ExtensionKey); + if (cfg === undefined) return; + + if (!Objects.areEquivalent(cfg.remotes, this._remotesCfg)) { + this._providerMap = new Map(defaultProviderMap); + + this._remotesCfg = cfg.remotes; + if (this._remotesCfg != null && this._remotesCfg.length > 0) { + for (const svc of this._remotesCfg) { + const provider = this.getCustomProvider(svc.type); + if (provider === undefined) continue; + + this._providerMap.set(svc.domain.toLowerCase(), provider); + } + + if (!silent) { + this._onDidChange.fire(); + } + } + } + } + + private static getCustomProvider(type: CustomRemoteType) { + switch (type) { + case CustomRemoteType.Bitbucket: return (domain: string, path: string) => new BitbucketService(domain, path, true); + case CustomRemoteType.BitbucketServer: return (domain: string, path: string) => new BitbucketServerService(domain, path, true); + case CustomRemoteType.GitHub: return (domain: string, path: string) => new GitHubService(domain, path, true); + case CustomRemoteType.GitLab: return (domain: string, path: string) => new GitLabService(domain, path, true); + } + return undefined; + } } \ No newline at end of file diff --git a/src/gitService.ts b/src/gitService.ts index 75f73af..f35e7c6 100644 --- a/src/gitService.ts +++ b/src/gitService.ts @@ -3,7 +3,8 @@ import { Functions, Iterables, Objects } from './system'; import { Disposable, Event, EventEmitter, FileSystemWatcher, Location, Position, Range, TextDocument, TextDocumentChangeEvent, TextEditor, Uri, workspace } from 'vscode'; import { IConfig } from './configuration'; import { DocumentSchemes, ExtensionKey, GlyphChars } from './constants'; -import { Git, GitAuthor, GitBlame, GitBlameCommit, GitBlameLine, GitBlameLines, GitBlameParser, GitBranch, GitBranchParser, GitCommit, GitDiff, GitDiffChunkLine, GitDiffParser, GitLog, GitLogCommit, GitLogParser, GitRemote, GitStash, GitStashParser, GitStatus, GitStatusFile, GitStatusParser, IGit, setDefaultEncoding } from './git/git'; +import { RemoteProviderFactory } from './git/remotes/factory'; +import { Git, GitAuthor, GitBlame, GitBlameCommit, GitBlameLine, GitBlameLines, GitBlameParser, GitBranch, GitBranchParser, GitCommit, GitDiff, GitDiffChunkLine, GitDiffParser, GitLog, GitLogCommit, GitLogParser, GitRemote, GitRemoteParser, GitStash, GitStashParser, GitStatus, GitStatusFile, GitStatusParser, IGit, setDefaultEncoding } from './git/git'; import { GitUri, IGitCommitInfo, IGitUriData } from './git/gitUri'; import { Logger } from './logger'; import * as fs from 'fs'; @@ -64,8 +65,9 @@ export const GitRepoSearchBy = { Sha: 'sha' as GitRepoSearchBy }; -export type RepoChangedReasons = 'stash' | 'unknown'; +export type RepoChangedReasons = 'remotes' | 'stash' | 'unknown'; export const RepoChangedReasons = { + Remotes: 'remotes' as RepoChangedReasons, Stash: 'stash' as RepoChangedReasons, Unknown: 'unknown' as RepoChangedReasons }; @@ -113,6 +115,7 @@ export class GitService extends Disposable { const subscriptions: Disposable[] = []; subscriptions.push(workspace.onDidChangeConfiguration(this._onConfigurationChanged, this)); + subscriptions.push(RemoteProviderFactory.onDidChange(this._onRemoteProviderChanged, this)); this._disposable = Disposable.from(...subscriptions); } @@ -193,6 +196,11 @@ export class GitService extends Disposable { this.config = cfg; } + private _onRemoteProviderChanged() { + this._remotesCache.clear(); + this._fireRepoChange(RepoChangedReasons.Remotes); + } + private _onTextDocumentChanged(e: TextDocumentChangeEvent) { if (!this.UseCaching) return; if (e.document.uri.scheme !== DocumentSchemes.File) return; @@ -895,7 +903,7 @@ export class GitService extends Disposable { } const data = await Git.remote(repoPath); - const remotes = data.split('\n').filter(_ => !!_).map(_ => new GitRemote(_)); + const remotes = GitRemoteParser.parse(data, repoPath); if (this.UseCaching) { this._remotesCache.set(repoPath, remotes); } diff --git a/src/quickPicks/branchHistory.ts b/src/quickPicks/branchHistory.ts index 979277a..77345c3 100644 --- a/src/quickPicks/branchHistory.ts +++ b/src/quickPicks/branchHistory.ts @@ -1,5 +1,5 @@ 'use strict'; -import { Arrays, Iterables, Strings } from '../system'; +import { Iterables, Strings } from '../system'; import { CancellationTokenSource, QuickPickOptions, Uri, window } from 'vscode'; import { Commands, ShowCommitSearchCommandArgs, ShowQuickBranchHistoryCommandArgs } from '../commands'; import { CommandQuickPickItem, CommitQuickPickItem, getQuickPickIgnoreFocusOut, showQuickPickProgress } from './common'; @@ -35,7 +35,7 @@ export class BranchHistoryQuickPick { } as ShowQuickBranchHistoryCommandArgs ]); - const remotes = Arrays.uniqueBy(await git.getRemotes((uri && uri.repoPath) || git.repoPath), _ => _.url, _ => !!_.provider); + const remotes = (await git.getRemotes((uri && uri.repoPath) || git.repoPath)).filter(r => r.provider !== undefined); if (remotes.length) { items.splice(0, 0, new OpenRemotesCommandQuickPickItem(remotes, { type: 'branch', diff --git a/src/quickPicks/commitDetails.ts b/src/quickPicks/commitDetails.ts index 9120ade..9049d4e 100644 --- a/src/quickPicks/commitDetails.ts +++ b/src/quickPicks/commitDetails.ts @@ -1,5 +1,5 @@ 'use strict'; -import { Arrays, Iterables, Strings } from '../system'; +import { Iterables, Strings } from '../system'; import { commands, QuickPickOptions, TextDocumentShowOptions, Uri, window } from 'vscode'; import { Commands, CopyMessageToClipboardCommandArgs, CopyShaToClipboardCommandArgs, DiffDirectoryCommandCommandArgs, DiffWithPreviousCommandArgs, ShowQuickCommitDetailsCommandArgs, StashApplyCommandArgs, StashDeleteCommandArgs } from '../commands'; import { CommandQuickPickItem, getQuickPickIgnoreFocusOut, KeyCommandQuickPickItem, OpenFileCommandQuickPickItem, OpenFilesCommandQuickPickItem, QuickPickItem } from './common'; @@ -153,7 +153,7 @@ export class CommitDetailsQuickPick { ])); if (!stash) { - const remotes = Arrays.uniqueBy(await git.getRemotes(commit.repoPath), _ => _.url, _ => !!_.provider); + const remotes = (await git.getRemotes(commit.repoPath)).filter(r => r.provider !== undefined); if (remotes.length) { items.splice(index++, 0, new OpenRemotesCommandQuickPickItem(remotes, { type: 'commit', diff --git a/src/quickPicks/commitFileDetails.ts b/src/quickPicks/commitFileDetails.ts index 5aa502b..ee143e2 100644 --- a/src/quickPicks/commitFileDetails.ts +++ b/src/quickPicks/commitFileDetails.ts @@ -1,5 +1,5 @@ 'use strict'; -import { Arrays, Iterables, Strings } from '../system'; +import { Iterables, Strings } from '../system'; import { QuickPickItem, QuickPickOptions, Uri, window } from 'vscode'; import { Commands, CopyMessageToClipboardCommandArgs, CopyShaToClipboardCommandArgs, DiffWithPreviousCommandArgs, DiffWithWorkingCommandArgs, ShowQuickCommitDetailsCommandArgs, ShowQuickCommitFileDetailsCommandArgs, ShowQuickFileHistoryCommandArgs } from '../commands'; import { CommandQuickPickItem, getQuickPickIgnoreFocusOut, KeyCommandQuickPickItem, OpenFileCommandQuickPickItem } from './common'; @@ -125,7 +125,7 @@ export class CommitFileDetailsQuickPick { } items.push(new OpenCommitFileRevisionCommandQuickPickItem(commit)); - const remotes = Arrays.uniqueBy(await git.getRemotes(commit.repoPath), _ => _.url, _ => !!_.provider); + const remotes = (await git.getRemotes(commit.repoPath)).filter(r => r.provider !== undefined); if (remotes.length) { if (commit.workingFileName && commit.status !== 'D') { const branch = await git.getBranch(commit.repoPath || git.repoPath); diff --git a/src/quickPicks/fileHistory.ts b/src/quickPicks/fileHistory.ts index d2d5216..c527ef7 100644 --- a/src/quickPicks/fileHistory.ts +++ b/src/quickPicks/fileHistory.ts @@ -1,5 +1,5 @@ 'use strict'; -import { Arrays, Iterables, Strings } from '../system'; +import { Iterables, Strings } from '../system'; import { CancellationTokenSource, QuickPickOptions, Uri, window } from 'vscode'; import { Commands, ShowQuickCurrentBranchHistoryCommandArgs, ShowQuickFileHistoryCommandArgs } from '../commands'; import { CommandQuickPickItem, CommitQuickPickItem, getQuickPickIgnoreFocusOut, showQuickPickProgress } from './common'; @@ -136,7 +136,7 @@ export class FileHistoryQuickPick { ])); } - const remotes = Arrays.uniqueBy(await git.getRemotes(uri.repoPath!), _ => _.url, _ => !!_.provider); + const remotes = (await git.getRemotes(uri.repoPath!)).filter(r => r.provider !== undefined); if (remotes.length) { items.splice(index++, 0, new OpenRemotesCommandQuickPickItem(remotes, { type: 'revision', diff --git a/src/views/remotesNode.ts b/src/views/remotesNode.ts index 309c9f2..ab5a547 100644 --- a/src/views/remotesNode.ts +++ b/src/views/remotesNode.ts @@ -1,5 +1,5 @@ 'use strict'; -import { Arrays, Iterables } from '../system'; +import { Iterables } from '../system'; import { ExtensionContext, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { ExplorerNode, MessageNode, ResourceType } from './explorerNode'; import { GitService, GitUri } from '../gitService'; @@ -14,7 +14,7 @@ export class RemotesNode extends ExplorerNode { } async getChildren(): Promise { - const remotes = Arrays.uniqueBy(await this.git.getRemotes(this.uri.repoPath!), r => r.url, r => !!r.provider); + const remotes = await this.git.getRemotes(this.uri.repoPath!); if (remotes === undefined || remotes.length === 0) return [new MessageNode('No remotes configured')]; remotes.sort((a, b) => a.name.localeCompare(b.name));