diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index aeefcde6c22..da236cabc70 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -265,6 +265,7 @@ import { listWorktrees, unstageAll, git, + IGitStringExecutionOptions, } from '../git' import { installGlobalLFSFilters, @@ -450,6 +451,8 @@ import { gatherCommitContext, } from '../copilot-conflict-context' import { resolveWithin } from '../path' +import { executionOptionsWithProgress, FetchProgressParser } from '../progress' +import { envForRemoteOperation } from '../git/environment' const LastSelectedRepositoryIDKey = 'last-selected-repository-id' @@ -6074,6 +6077,118 @@ export class AppStore extends TypedBaseStore { }) } + public async _fetchRemoteBranch( + repository: Repository, + branch: Branch + ): Promise { + return this.withRefreshedGitHubRepository(repository, repo => { + return this.performFetchRemoteBranch(repo, branch) + }) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + private async performFetchRemoteBranch( + repository: Repository, + branch: Branch + ) { + const isRemote = branch.type === BranchType.Remote + if (!isRemote) { + return + } + + const remoteName = branch.remoteName + const remoteBranchName = branch.nameWithoutRemote + + if (!remoteName) { + throw new Error('Remote name not found') + } + + const isBackgroundTask = false + const gitStore = this.gitStoreCache.get(repository) + + // repository.url + const remote = { name: remoteName, url: 'file://' } + + const _fetchRemoteBranchProgressCallback = (progress: any) => { + console.log(progress, ' progress ') + } + + const title = `Fetching ${remoteName}` + const kind = 'fetch' + let opts: IGitStringExecutionOptions = { + successExitCodes: new Set([0]), + } + if (remote.url) { + opts = { + ...opts, + env: await envForRemoteOperation(remote.url), + } + } + + opts = await executionOptionsWithProgress( + { ...opts, trackLFSProgress: true, isBackgroundTask }, + new FetchProgressParser(), + progress => { + // In addition to progress output from the remote end and from + // git itself, the stderr output from pull contains information + // about ref updates. We don't need to bring those into the progress + // stream so we'll just punt on anything we don't know about for now. + if (progress.kind === 'context') { + if (!progress.text.startsWith('remote: Counting objects')) { + return + } + } + + const description = + progress.kind === 'progress' ? progress.details.text : progress.text + const value = progress.percent + + _fetchRemoteBranchProgressCallback({ + kind, + title, + description, + value, + remote: remote.name, + }) + } + ) + + // Initial progress + _fetchRemoteBranchProgressCallback({ + kind, + title, + value: 0, + remote: remote.name, + }) + + const fetchFn = async () => { + await git( + [ + 'fetch', + '--progress', + '--prune', + '--recurse-submodules=on-demand', + remoteName, + remoteBranchName, + ], + repository.path, + 'fetchRemoteBranch', + opts + ) + return true + } + + const fetchSucceeded = await gitStore.performFailableOperation(fetchFn, { + backgroundTask: isBackgroundTask, + }) + + if (fetchSucceeded) { + await this._refreshRepository(repository) + } else { + console.error('Fetch did not succeed') + } + } + public async _resetHardToUpstream(repository: Repository): Promise { const { branchesState } = this.repositoryStateCache.get(repository) const { tip } = branchesState diff --git a/app/src/ui/branches/branch-list-item-context-menu.tsx b/app/src/ui/branches/branch-list-item-context-menu.tsx index bb409b17d8f..38cdad2c05b 100644 --- a/app/src/ui/branches/branch-list-item-context-menu.tsx +++ b/app/src/ui/branches/branch-list-item-context-menu.tsx @@ -5,6 +5,7 @@ import { assertNever } from '../../lib/fatal-error' interface IBranchContextMenuConfig { name: string + remoteName?: string | null nameWithoutRemote: string isLocal: boolean repoType: RepoType | undefined @@ -14,6 +15,7 @@ interface IBranchContextMenuConfig { onViewPullRequestOnGitHub?: () => void onSetAsDefaultBranch?: (branchName: string) => void onDeleteBranch?: (branchName: string) => void + onFetchRemoteBranch?: (branchName: string) => void } export function generateBranchContextMenuItems( @@ -22,6 +24,7 @@ export function generateBranchContextMenuItems( const { name, nameWithoutRemote, + remoteName, isLocal, repoType, isInUseByOtherWorktree, @@ -30,6 +33,7 @@ export function generateBranchContextMenuItems( onViewPullRequestOnGitHub, onSetAsDefaultBranch, onDeleteBranch, + onFetchRemoteBranch, } = config const items = new Array() @@ -41,6 +45,14 @@ export function generateBranchContextMenuItems( }) } + if (!isLocal && onFetchRemoteBranch !== undefined) { + items.push({ + label: getRemoteFetchBranchLabel(), + action: () => onFetchRemoteBranch(name), + enabled: !!remoteName, + }) + } + items.push({ label: __DARWIN__ ? 'Copy Branch Name' : 'Copy branch name', action: () => clipboard.writeText(name), @@ -104,3 +116,7 @@ function getViewPullRequestLabel(repoType: RepoType): string { return assertNever(repoType, `Unknown repo type: ${repoType}`) } } + +function getRemoteFetchBranchLabel(): string { + return `Fetch branch` +} diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index 91eac3bab23..eb9500c1016 100644 --- a/app/src/ui/branches/branch-list.tsx +++ b/app/src/ui/branches/branch-list.tsx @@ -144,6 +144,9 @@ interface IBranchListProps { /** Optional: Callback for if delete context menu should exist */ readonly onDeleteBranch?: (branchName: string) => void + + /** Optional: Callback if pull option for remote branch context menu should exist */ + readonly onFetchRemoteBranch?: (branchName: string) => void } /** The Branches list component. */ @@ -234,7 +237,12 @@ export class BranchList extends React.Component { ) => { event.preventDefault() - const { onRenameBranch, onDeleteBranch, onSetAsDefaultBranch } = this.props + const { + onRenameBranch, + onDeleteBranch, + onSetAsDefaultBranch, + onFetchRemoteBranch, + } = this.props if ( onRenameBranch === undefined && @@ -244,12 +252,13 @@ export class BranchList extends React.Component { return } - const { type, name, nameWithoutRemote } = item.branch + const { type, name, nameWithoutRemote, remoteName } = item.branch const isLocal = type === BranchType.Local const isInUseByOtherWorktree = !!this.inUseByOtherWorktreeName(item) const items = generateBranchContextMenuItems({ name, + remoteName, nameWithoutRemote, isLocal, repoType: this.props.repository.gitHubRepository?.type, @@ -260,6 +269,7 @@ export class BranchList extends React.Component { ? undefined : onSetAsDefaultBranch, onDeleteBranch, + onFetchRemoteBranch, }) showContextualMenu(items) diff --git a/app/src/ui/branches/branches-container.tsx b/app/src/ui/branches/branches-container.tsx index 9bb9e46898e..cc7351b0020 100644 --- a/app/src/ui/branches/branches-container.tsx +++ b/app/src/ui/branches/branches-container.tsx @@ -53,6 +53,7 @@ interface IBranchesContainerProps { readonly onRenameBranch: (branchName: string) => void readonly onSetAsDefaultBranch: (branchName: string) => void readonly onDeleteBranch: (branchName: string) => void + readonly onFetchRemoteBranch: (branchName: string) => void readonly branchSortOrder: BranchSortOrder @@ -293,6 +294,7 @@ export class BranchesContainer extends React.Component< onRenameBranch={this.props.onRenameBranch} onSetAsDefaultBranch={this.props.onSetAsDefaultBranch} onDeleteBranch={this.props.onDeleteBranch} + onFetchRemoteBranch={this.props.onFetchRemoteBranch} /> ) case BranchesTab.PullRequests: { diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 8a4e53bfee9..de51c8e3129 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -821,6 +821,14 @@ export class Dispatcher { return this.appStore._pull(repository) } + /** Pull remote branch by name */ + public fetchRemoteBranch( + repository: Repository, + branch: Branch + ): Promise { + return this.appStore._fetchRemoteBranch(repository, branch) + } + public async pullAllRepositories(): Promise { try { await this.appStore._pullAllRepositories() diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index c91dd791222..da6bda778d9 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -118,6 +118,7 @@ export class BranchDropdown extends React.Component { branchSortOrder={this.props.branchSortOrder} emoji={this.props.emoji} onDeleteBranch={this.onDeleteBranch} + onFetchRemoteBranch={this.onFetchRemoteBranch} onRenameBranch={this.onRenameBranch} onSetAsDefaultBranch={this.onSetAsDefaultBranch} underlineLinks={this.props.underlineLinks} @@ -443,6 +444,20 @@ export class BranchDropdown extends React.Component { }) } + private onFetchRemoteBranch = (branchName: string) => { + const branch = this.getBranchWithName(branchName) + const { dispatcher, repository } = this.props + + if (!branch) { + return + } + + // Only fetch remote branch + if (branch.type === BranchType.Remote) { + dispatcher.fetchRemoteBranch(repository, branch) + } + } + private onBadgeClick = () => { // The badge can't be clicked while the CI status popover is shown, because // in that case the Popover component will recognize the "click outside"