From 3ceda0cb7039eb8faa036f474003ba88af8c545b Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Thu, 4 Jun 2026 20:45:48 +0600 Subject: [PATCH 1/3] Add pull option to remote branch context menu Add Pull branch option to the right click context menu for remote branches in the branch dropdown. This allows pulling a remote branch directly without switching to it first. --- app/src/lib/stores/app-store.ts | 20 ++++++++ .../branch-list-item-context-menu.tsx | 24 ++++++++++ app/src/ui/branches/branch-list.tsx | 11 ++++- app/src/ui/branches/branches-container.tsx | 2 + app/src/ui/dispatcher/dispatcher.ts | 8 ++++ app/src/ui/toolbar/branch-dropdown.tsx | 47 +++++++++++++++++++ 6 files changed, 111 insertions(+), 1 deletion(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index aeefcde6c22..7d62a81af41 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -6074,6 +6074,26 @@ export class AppStore extends TypedBaseStore { }) } + public async _pullRemoteBranch( + repository: Repository, + branch: Branch + ): Promise { + return this.withRefreshedGitHubRepository(repository, repo => { + return this.performPullRemoteBranch(repo, branch) + }) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + private async performPullRemoteBranch( + repository: Repository, + branch: Branch + ) { + console.log(repository, ' repository ') + console.log(branch, ' branch ') + + return + } + 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..2a2cbcfeaa7 100644 --- a/app/src/ui/branches/branch-list-item-context-menu.tsx +++ b/app/src/ui/branches/branch-list-item-context-menu.tsx @@ -14,6 +14,7 @@ interface IBranchContextMenuConfig { onViewPullRequestOnGitHub?: () => void onSetAsDefaultBranch?: (branchName: string) => void onDeleteBranch?: (branchName: string) => void + onPullRemoteBranch?: (branchName: string) => void } export function generateBranchContextMenuItems( @@ -30,6 +31,7 @@ export function generateBranchContextMenuItems( onViewPullRequestOnGitHub, onSetAsDefaultBranch, onDeleteBranch, + onPullRemoteBranch, } = config const items = new Array() @@ -41,6 +43,14 @@ export function generateBranchContextMenuItems( }) } + if (onPullRemoteBranch !== undefined) { + items.push({ + label: getRemotePullBranchLabel(), + action: () => onPullRemoteBranch(name), + enabled: true, + }) + } + items.push({ label: __DARWIN__ ? 'Copy Branch Name' : 'Copy branch name', action: () => clipboard.writeText(name), @@ -104,3 +114,17 @@ function getViewPullRequestLabel(repoType: RepoType): string { return assertNever(repoType, `Unknown repo type: ${repoType}`) } } + +function getRemotePullBranchLabel(): string { + return 'Pull branch' + // switch (repoType) { + // case 'github': + // return 'Pull branch from Github' + // case 'bitbucket': + // return 'Pull branch from Bitbucket' + // case 'gitlab': + // return 'Pull branch from GitLab' + // default: + // return assertNever(repoType, `Unknown repo type: ${repoType}`) + // } +} diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index 91eac3bab23..a53116caeb6 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 onPullRemoteBranch?: (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, + onPullRemoteBranch, + } = this.props if ( onRenameBranch === undefined && @@ -260,6 +268,7 @@ export class BranchList extends React.Component { ? undefined : onSetAsDefaultBranch, onDeleteBranch, + onPullRemoteBranch, }) showContextualMenu(items) diff --git a/app/src/ui/branches/branches-container.tsx b/app/src/ui/branches/branches-container.tsx index 9bb9e46898e..d322effe573 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 onPullRemoteBranch: (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} + onPullRemoteBranch={this.props.onPullRemoteBranch} /> ) case BranchesTab.PullRequests: { diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 8a4e53bfee9..c2f1ca11007 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 pullRemoteBranch( + repository: Repository, + branch: Branch + ): Promise { + return this.appStore._pullRemoteBranch(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..0c2f0c1196c 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} + onPullRemoteBranch={this.onPullRemoteBranch} onRenameBranch={this.onRenameBranch} onSetAsDefaultBranch={this.onSetAsDefaultBranch} underlineLinks={this.props.underlineLinks} @@ -443,6 +444,52 @@ export class BranchDropdown extends React.Component { }) } + private onPullRemoteBranch = async (branchName: string) => { + const branch = this.getBranchWithName(branchName) + const { dispatcher, repository } = this.props + + if (branch === undefined) { + return + } + + // console.clear() + // console.log(repository, ' repository ') + // console.log(dispatcher, ' dispatcher ') + // console.log(branch, ' branch ') + // console.log(BranchType, ' BranchType ') + + if (branch.type === BranchType.Remote) { + // dispatcher.showPopup({ + // type: PopupType.PullRemoteBranch, + // repository, + // branch, + // existsOnRemote: true, + // }) + + dispatcher.pullRemoteBranch(repository, branch) + } + + // if (branch.type === BranchType.Remote) { + // dispatcher.showPopup({ + // type: PopupType.DeleteRemoteBranch, + // repository, + // branch, + // }) + // return + // } + + // const aheadBehind = await dispatcher.getBranchAheadBehind( + // repository, + // branch + // ) + // dispatcher.showPopup({ + // type: PopupType.DeleteBranch, + // repository, + // branch, + // existsOnRemote: aheadBehind !== null, + // }) + } + 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" From b706d281c9a77e862f9944633ee824ec4bd5e22c Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Thu, 4 Jun 2026 22:37:52 +0600 Subject: [PATCH 2/3] Implement fetch logic for pulling remote branches Add the actual git fetch implementation for the 'Pull branch' context menu option on remote branches. This fetches the specific branch from the remote and refreshes the repository state. This is a basic prototype - will be refined in follow-up commits. Refs #173 --- app/src/lib/stores/app-store.ts | 50 +++++++++++++++++-- .../branch-list-item-context-menu.tsx | 2 +- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 7d62a81af41..ac86d6a5e59 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -6088,10 +6088,54 @@ export class AppStore extends TypedBaseStore { repository: Repository, branch: Branch ) { - console.log(repository, ' repository ') - console.log(branch, ' branch ') + const remoteName = branch.remoteName + const remoteBranchName = branch.nameWithoutRemote - return + if (!remoteName) { + throw new Error('Remote name not found') + } + + // await git( + // ['fetch', remoteName, remoteBranchName], + // repository.path, + // 'pullRemoteBranch' + // ) + const backgroundTask = false + const gitStore = this.gitStoreCache.get(repository) + const remote = { name: remoteName, url: '' } + // const progressCallback = (progress: IFetchProgress) => { + // console.log(progress, ' progress ') + // } + + const fetchFn = async () => { + await git( + [ + 'fetch', + // ...(progressCallback ? ['--progress'] : []), + '--progress', + '--prune', + '--recurse-submodules=on-demand', + remoteName, + remoteBranchName, + ], + repository.path, + 'pullRemoteBranch' + ) + return true + } + + const fetchSucceeded = await gitStore.performFailableOperation(fetchFn, { + backgroundTask, + }) + + if (fetchSucceeded) { + await updateRemoteHEAD(repository, remote, backgroundTask).catch(e => + log.error('Failed updating remote HEAD', e) + ) + await this._refreshRepository(repository) + } else { + console.error('Fetch did not succeed') + } } public async _resetHardToUpstream(repository: Repository): Promise { 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 2a2cbcfeaa7..5a48268f8f2 100644 --- a/app/src/ui/branches/branch-list-item-context-menu.tsx +++ b/app/src/ui/branches/branch-list-item-context-menu.tsx @@ -43,7 +43,7 @@ export function generateBranchContextMenuItems( }) } - if (onPullRemoteBranch !== undefined) { + if (onPullRemoteBranch !== undefined && !isLocal) { items.push({ label: getRemotePullBranchLabel(), action: () => onPullRemoteBranch(name), From 08953262a4956f77447f5166c00545a47d34bda1 Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Fri, 5 Jun 2026 04:09:19 +0600 Subject: [PATCH 3/3] Rename pull to fetch and restrict to remote-only branches Closes #173 --- app/src/lib/stores/app-store.ts | 89 +++++++++++++++---- .../branch-list-item-context-menu.tsx | 28 +++--- app/src/ui/branches/branch-list.tsx | 9 +- app/src/ui/branches/branches-container.tsx | 4 +- app/src/ui/dispatcher/dispatcher.ts | 4 +- app/src/ui/toolbar/branch-dropdown.tsx | 42 ++------- 6 files changed, 94 insertions(+), 82 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index ac86d6a5e59..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,20 +6077,25 @@ export class AppStore extends TypedBaseStore { }) } - public async _pullRemoteBranch( + public async _fetchRemoteBranch( repository: Repository, branch: Branch ): Promise { return this.withRefreshedGitHubRepository(repository, repo => { - return this.performPullRemoteBranch(repo, branch) + return this.performFetchRemoteBranch(repo, branch) }) } /** This shouldn't be called directly. See `Dispatcher`. */ - private async performPullRemoteBranch( + private async performFetchRemoteBranch( repository: Repository, branch: Branch ) { + const isRemote = branch.type === BranchType.Remote + if (!isRemote) { + return + } + const remoteName = branch.remoteName const remoteBranchName = branch.nameWithoutRemote @@ -6095,23 +6103,68 @@ export class AppStore extends TypedBaseStore { throw new Error('Remote name not found') } - // await git( - // ['fetch', remoteName, remoteBranchName], - // repository.path, - // 'pullRemoteBranch' - // ) - const backgroundTask = false + const isBackgroundTask = false const gitStore = this.gitStoreCache.get(repository) - const remote = { name: remoteName, url: '' } - // const progressCallback = (progress: IFetchProgress) => { - // console.log(progress, ' progress ') - // } + + // 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', - // ...(progressCallback ? ['--progress'] : []), '--progress', '--prune', '--recurse-submodules=on-demand', @@ -6119,19 +6172,17 @@ export class AppStore extends TypedBaseStore { remoteBranchName, ], repository.path, - 'pullRemoteBranch' + 'fetchRemoteBranch', + opts ) return true } const fetchSucceeded = await gitStore.performFailableOperation(fetchFn, { - backgroundTask, + backgroundTask: isBackgroundTask, }) if (fetchSucceeded) { - await updateRemoteHEAD(repository, remote, backgroundTask).catch(e => - log.error('Failed updating remote HEAD', e) - ) await this._refreshRepository(repository) } else { console.error('Fetch did not succeed') 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 5a48268f8f2..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,7 +15,7 @@ interface IBranchContextMenuConfig { onViewPullRequestOnGitHub?: () => void onSetAsDefaultBranch?: (branchName: string) => void onDeleteBranch?: (branchName: string) => void - onPullRemoteBranch?: (branchName: string) => void + onFetchRemoteBranch?: (branchName: string) => void } export function generateBranchContextMenuItems( @@ -23,6 +24,7 @@ export function generateBranchContextMenuItems( const { name, nameWithoutRemote, + remoteName, isLocal, repoType, isInUseByOtherWorktree, @@ -31,7 +33,7 @@ export function generateBranchContextMenuItems( onViewPullRequestOnGitHub, onSetAsDefaultBranch, onDeleteBranch, - onPullRemoteBranch, + onFetchRemoteBranch, } = config const items = new Array() @@ -43,11 +45,11 @@ export function generateBranchContextMenuItems( }) } - if (onPullRemoteBranch !== undefined && !isLocal) { + if (!isLocal && onFetchRemoteBranch !== undefined) { items.push({ - label: getRemotePullBranchLabel(), - action: () => onPullRemoteBranch(name), - enabled: true, + label: getRemoteFetchBranchLabel(), + action: () => onFetchRemoteBranch(name), + enabled: !!remoteName, }) } @@ -115,16 +117,6 @@ function getViewPullRequestLabel(repoType: RepoType): string { } } -function getRemotePullBranchLabel(): string { - return 'Pull branch' - // switch (repoType) { - // case 'github': - // return 'Pull branch from Github' - // case 'bitbucket': - // return 'Pull branch from Bitbucket' - // case 'gitlab': - // return 'Pull branch from GitLab' - // default: - // 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 a53116caeb6..eb9500c1016 100644 --- a/app/src/ui/branches/branch-list.tsx +++ b/app/src/ui/branches/branch-list.tsx @@ -146,7 +146,7 @@ interface IBranchListProps { readonly onDeleteBranch?: (branchName: string) => void /** Optional: Callback if pull option for remote branch context menu should exist */ - readonly onPullRemoteBranch?: (branchName: string) => void + readonly onFetchRemoteBranch?: (branchName: string) => void } /** The Branches list component. */ @@ -241,7 +241,7 @@ export class BranchList extends React.Component { onRenameBranch, onDeleteBranch, onSetAsDefaultBranch, - onPullRemoteBranch, + onFetchRemoteBranch, } = this.props if ( @@ -252,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, @@ -268,7 +269,7 @@ export class BranchList extends React.Component { ? undefined : onSetAsDefaultBranch, onDeleteBranch, - onPullRemoteBranch, + onFetchRemoteBranch, }) showContextualMenu(items) diff --git a/app/src/ui/branches/branches-container.tsx b/app/src/ui/branches/branches-container.tsx index d322effe573..cc7351b0020 100644 --- a/app/src/ui/branches/branches-container.tsx +++ b/app/src/ui/branches/branches-container.tsx @@ -53,7 +53,7 @@ interface IBranchesContainerProps { readonly onRenameBranch: (branchName: string) => void readonly onSetAsDefaultBranch: (branchName: string) => void readonly onDeleteBranch: (branchName: string) => void - readonly onPullRemoteBranch: (branchName: string) => void + readonly onFetchRemoteBranch: (branchName: string) => void readonly branchSortOrder: BranchSortOrder @@ -294,7 +294,7 @@ export class BranchesContainer extends React.Component< onRenameBranch={this.props.onRenameBranch} onSetAsDefaultBranch={this.props.onSetAsDefaultBranch} onDeleteBranch={this.props.onDeleteBranch} - onPullRemoteBranch={this.props.onPullRemoteBranch} + onFetchRemoteBranch={this.props.onFetchRemoteBranch} /> ) case BranchesTab.PullRequests: { diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index c2f1ca11007..de51c8e3129 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -822,11 +822,11 @@ export class Dispatcher { } /** Pull remote branch by name */ - public pullRemoteBranch( + public fetchRemoteBranch( repository: Repository, branch: Branch ): Promise { - return this.appStore._pullRemoteBranch(repository, branch) + return this.appStore._fetchRemoteBranch(repository, branch) } public async pullAllRepositories(): Promise { diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index 0c2f0c1196c..da6bda778d9 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -118,7 +118,7 @@ export class BranchDropdown extends React.Component { branchSortOrder={this.props.branchSortOrder} emoji={this.props.emoji} onDeleteBranch={this.onDeleteBranch} - onPullRemoteBranch={this.onPullRemoteBranch} + onFetchRemoteBranch={this.onFetchRemoteBranch} onRenameBranch={this.onRenameBranch} onSetAsDefaultBranch={this.onSetAsDefaultBranch} underlineLinks={this.props.underlineLinks} @@ -444,50 +444,18 @@ export class BranchDropdown extends React.Component { }) } - private onPullRemoteBranch = async (branchName: string) => { + private onFetchRemoteBranch = (branchName: string) => { const branch = this.getBranchWithName(branchName) const { dispatcher, repository } = this.props - if (branch === undefined) { + if (!branch) { return } - // console.clear() - // console.log(repository, ' repository ') - // console.log(dispatcher, ' dispatcher ') - // console.log(branch, ' branch ') - // console.log(BranchType, ' BranchType ') - + // Only fetch remote branch if (branch.type === BranchType.Remote) { - // dispatcher.showPopup({ - // type: PopupType.PullRemoteBranch, - // repository, - // branch, - // existsOnRemote: true, - // }) - - dispatcher.pullRemoteBranch(repository, branch) + dispatcher.fetchRemoteBranch(repository, branch) } - - // if (branch.type === BranchType.Remote) { - // dispatcher.showPopup({ - // type: PopupType.DeleteRemoteBranch, - // repository, - // branch, - // }) - // return - // } - - // const aheadBehind = await dispatcher.getBranchAheadBehind( - // repository, - // branch - // ) - // dispatcher.showPopup({ - // type: PopupType.DeleteBranch, - // repository, - // branch, - // existsOnRemote: aheadBehind !== null, - // }) } private onBadgeClick = () => {