From 3ceda0cb7039eb8faa036f474003ba88af8c545b Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Thu, 4 Jun 2026 20:45:48 +0600 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 = () => { From 21197a3fbb102f52450b137c246a5abb49703f40 Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Sun, 7 Jun 2026 21:06:03 +0600 Subject: [PATCH 04/12] Add isDefault flag to branch context menu items --- app/src/ui/branches/branch-list.tsx | 3 ++- app/src/ui/toolbar/branch-dropdown.tsx | 13 ++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index eb9500c1016..d1977d3bfb4 100644 --- a/app/src/ui/branches/branch-list.tsx +++ b/app/src/ui/branches/branch-list.tsx @@ -255,12 +255,13 @@ export class BranchList extends React.Component { const { type, name, nameWithoutRemote, remoteName } = item.branch const isLocal = type === BranchType.Local const isInUseByOtherWorktree = !!this.inUseByOtherWorktreeName(item) - + const isDefault = nameWithoutRemote === this.props.repository.defaultBranch const items = generateBranchContextMenuItems({ name, remoteName, nameWithoutRemote, isLocal, + isDefault, repoType: this.props.repository.gitHubRepository?.type, isInUseByOtherWorktree, onRenameBranch, diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index da6bda778d9..c50cc272b75 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -317,10 +317,12 @@ export class BranchDropdown extends React.Component { } const { name, type, nameWithoutRemote } = tip.branch + const isDefault = nameWithoutRemote === this.props.repository.defaultBranch const items = generateBranchContextMenuItems({ name, nameWithoutRemote, isLocal: type === BranchType.Local, + isDefault, repoType: this.props.repository.gitHubRepository?.type, isInUseByOtherWorktree: false, onRenameBranch: this.onRenameBranch, @@ -332,10 +334,7 @@ export class BranchDropdown extends React.Component { onViewPullRequestOnGitHub: this.props.currentPullRequest ? this.onViewPullRequestOnGithub : undefined, - onSetAsDefaultBranch: - nameWithoutRemote === this.props.repository.defaultBranch - ? undefined - : this.onSetAsDefaultBranch, + onSetAsDefaultBranch: isDefault ? undefined : this.onSetAsDefaultBranch, onDeleteBranch: this.onDeleteBranch, }) @@ -453,9 +452,9 @@ export class BranchDropdown extends React.Component { } // Only fetch remote branch - if (branch.type === BranchType.Remote) { - dispatcher.fetchRemoteBranch(repository, branch) - } + // if (branch.type === BranchType.Remote) { + // } + dispatcher.fetchRemoteBranch(repository, branch) } private onBadgeClick = () => { From c0e24bf7535839b915ff696face2c66c08d70ffe Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Sun, 7 Jun 2026 23:02:30 +0600 Subject: [PATCH 05/12] Extend fetch to local branches with upstream tracking Refactor fetchRemoteBranch to support both remote and local branches with upstream tracking Restructure context menu to show fetch for non default, non current branches Improve error handling with merge conflict detection and logging Remove remoteName requirement from branch context menu config --- app/src/lib/stores/app-store.ts | 90 +++++++++++++------ .../branch-list-item-context-menu.tsx | 27 +++--- app/src/ui/branches/branch-list.tsx | 4 +- app/src/ui/toolbar/branch-dropdown.tsx | 1 + 4 files changed, 83 insertions(+), 39 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index da236cabc70..565e1bc0b26 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -266,6 +266,7 @@ import { unstageAll, git, IGitStringExecutionOptions, + IGitStringResult, } from '../git' import { installGlobalLFSFilters, @@ -6092,16 +6093,18 @@ export class AppStore extends TypedBaseStore { branch: Branch ) { const isRemote = branch.type === BranchType.Remote - if (!isRemote) { - return - } - const remoteName = branch.remoteName - const remoteBranchName = branch.nameWithoutRemote + const remoteName = isRemote ? branch.remoteName : branch.upstreamRemoteName + const remoteBranchName = isRemote + ? branch.nameWithoutRemote + : branch.upstreamWithoutRemote if (!remoteName) { throw new Error('Remote name not found') } + if (!remoteBranchName) { + throw new Error('Remote branch not found') + } const isBackgroundTask = false const gitStore = this.gitStoreCache.get(repository) @@ -6160,32 +6163,69 @@ export class AppStore extends TypedBaseStore { value: 0, remote: remote.name, }) + console.log(`[UI] Starting background update for: ${remoteBranchName}...`) + + const fetchFn = async ( + isRemote: boolean, + opts: IGitStringExecutionOptions = {} + ): Promise => { + const flags = isRemote + ? ['fetch', '--progress', '--recurse-submodules=on-demand', remoteName] + : [ + 'fetch', + '--progress', + // '--show-forced-updates', + // '--no-write-fetch-head', + '--recurse-submodules=on-demand', + remoteName, + ] + + const branchTarget = isRemote + ? remoteBranchName + : `${remoteBranchName}:${remoteBranchName}` + const actionName = isRemote ? 'fetchRemoteBranch' : 'fetchLocalBranch' + + const executionOpts = isRemote + ? opts + : { + ...opts, + successExitCodes: new Set([0, 1]), + } - const fetchFn = async () => { - await git( - [ - 'fetch', - '--progress', - '--prune', - '--recurse-submodules=on-demand', - remoteName, - remoteBranchName, - ], + return await git( + [...flags, branchTarget], repository.path, - 'fetchRemoteBranch', - opts + actionName, + executionOpts ) - return true } - const fetchSucceeded = await gitStore.performFailableOperation(fetchFn, { - backgroundTask: isBackgroundTask, - }) + try { + await gitStore.performFailableOperation( + async () => { + const result = await fetchFn(isRemote, opts) + if ( + !isRemote && + result && + (result.stderr?.includes('rejected') || + result.stderr?.includes('non-fast-forward')) + ) { + console.error( + `[UI/ERROR] Merge conflict/Divergence detected on ${remoteBranchName}.` + ) - if (fetchSucceeded) { - await this._refreshRepository(repository) - } else { - console.error('Fetch did not succeed') + this.popupManager.addErrorPopup(new Error(result.stderr)) + } + + await this._refreshRepository(repository) + console.log(`[UI] Success! ${remoteBranchName} updated cleanly.`) + }, + { + backgroundTask: isBackgroundTask, + } + ) + } finally { + console.log(`[UI] Stopping spinner for ${remoteBranchName}.`) } } 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 38cdad2c05b..9c87d87e5e6 100644 --- a/app/src/ui/branches/branch-list-item-context-menu.tsx +++ b/app/src/ui/branches/branch-list-item-context-menu.tsx @@ -5,9 +5,10 @@ import { assertNever } from '../../lib/fatal-error' interface IBranchContextMenuConfig { name: string - remoteName?: string | null nameWithoutRemote: string + isDefault: boolean isLocal: boolean + isCurrentBranch: boolean repoType: RepoType | undefined isInUseByOtherWorktree: boolean onRenameBranch?: (branchName: string) => void @@ -24,8 +25,9 @@ export function generateBranchContextMenuItems( const { name, nameWithoutRemote, - remoteName, isLocal, + isDefault, + isCurrentBranch, repoType, isInUseByOtherWorktree, onRenameBranch, @@ -36,7 +38,6 @@ export function generateBranchContextMenuItems( onFetchRemoteBranch, } = config const items = new Array() - if (onRenameBranch !== undefined) { items.push({ label: 'Rename…', @@ -45,14 +46,6 @@ 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), @@ -79,6 +72,16 @@ export function generateBranchContextMenuItems( }) } + // This should be the selected branch. + if (!isDefault && !isCurrentBranch && onFetchRemoteBranch !== undefined) { + items.push({ type: 'separator' }) + items.push({ + label: getRemoteFetchBranchLabel(), + action: () => onFetchRemoteBranch(name), + enabled: true, + }) + } + if (onDeleteBranch !== undefined && !isInUseByOtherWorktree) { items.push({ type: 'separator' }) items.push({ @@ -118,5 +121,5 @@ function getViewPullRequestLabel(repoType: RepoType): string { } function getRemoteFetchBranchLabel(): string { - return `Fetch branch` + return __DARWIN__ ? 'Fetch Branch' : 'Fetch branch' } diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index d1977d3bfb4..4983a7c5f6c 100644 --- a/app/src/ui/branches/branch-list.tsx +++ b/app/src/ui/branches/branch-list.tsx @@ -252,16 +252,16 @@ export class BranchList extends React.Component { return } - const { type, name, nameWithoutRemote, remoteName } = item.branch + const { type, name, nameWithoutRemote } = item.branch const isLocal = type === BranchType.Local const isInUseByOtherWorktree = !!this.inUseByOtherWorktreeName(item) const isDefault = nameWithoutRemote === this.props.repository.defaultBranch const items = generateBranchContextMenuItems({ name, - remoteName, nameWithoutRemote, isLocal, isDefault, + isCurrentBranch: item.branch.name === this.props.currentBranch?.name, repoType: this.props.repository.gitHubRepository?.type, isInUseByOtherWorktree, onRenameBranch, diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index c50cc272b75..ac1529fe0f4 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -323,6 +323,7 @@ export class BranchDropdown extends React.Component { nameWithoutRemote, isLocal: type === BranchType.Local, isDefault, + isCurrentBranch: true, repoType: this.props.repository.gitHubRepository?.type, isInUseByOtherWorktree: false, onRenameBranch: this.onRenameBranch, From 6b42199f1ad8492acd7995b4ef823889991f14e6 Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Mon, 8 Jun 2026 15:15:39 +0600 Subject: [PATCH 06/12] Rename fetch to support both remote and local branches Rename _fetchRemoteBranch to _fetchRemoteOrLocalBranch Remove isDefault check from context menu; fetch now available for any noncurrent branch Wrap fetch operation in withPushPullFetch --- app/src/lib/stores/app-store.ts | 31 ++++++++++++------- .../branch-list-item-context-menu.tsx | 10 +++--- app/src/ui/branches/branch-list.tsx | 8 ++--- app/src/ui/branches/branches-container.tsx | 4 +-- app/src/ui/dispatcher/dispatcher.ts | 4 +-- app/src/ui/toolbar/branch-dropdown.tsx | 21 ++++++------- 6 files changed, 40 insertions(+), 38 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 565e1bc0b26..630c084c181 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -6078,17 +6078,17 @@ export class AppStore extends TypedBaseStore { }) } - public async _fetchRemoteBranch( + public async _fetchRemoteOrLocalBranch( repository: Repository, branch: Branch ): Promise { return this.withRefreshedGitHubRepository(repository, repo => { - return this.performFetchRemoteBranch(repo, branch) + return this.performFetchRemoteOrLocalBranch(repo, branch) }) } /** This shouldn't be called directly. See `Dispatcher`. */ - private async performFetchRemoteBranch( + private async performFetchRemoteOrLocalBranch( repository: Repository, branch: Branch ) { @@ -6112,7 +6112,7 @@ export class AppStore extends TypedBaseStore { // repository.url const remote = { name: remoteName, url: 'file://' } - const _fetchRemoteBranchProgressCallback = (progress: any) => { + const progressCb = (progress: any) => { console.log(progress, ' progress ') } @@ -6136,6 +6136,7 @@ export class AppStore extends TypedBaseStore { // 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. + console.log(progress, ' progress ') if (progress.kind === 'context') { if (!progress.text.startsWith('remote: Counting objects')) { return @@ -6146,7 +6147,7 @@ export class AppStore extends TypedBaseStore { progress.kind === 'progress' ? progress.details.text : progress.text const value = progress.percent - _fetchRemoteBranchProgressCallback({ + progressCb({ kind, title, description, @@ -6157,7 +6158,7 @@ export class AppStore extends TypedBaseStore { ) // Initial progress - _fetchRemoteBranchProgressCallback({ + progressCb({ kind, title, value: 0, @@ -6200,7 +6201,7 @@ export class AppStore extends TypedBaseStore { ) } - try { + const execFetchFn = async () => { await gitStore.performFailableOperation( async () => { const result = await fetchFn(isRemote, opts) @@ -6210,20 +6211,26 @@ export class AppStore extends TypedBaseStore { (result.stderr?.includes('rejected') || result.stderr?.includes('non-fast-forward')) ) { - console.error( - `[UI/ERROR] Merge conflict/Divergence detected on ${remoteBranchName}.` + this.emitError( + new ErrorWithMetadata(new Error(result.stderr), { repository }) ) - - this.popupManager.addErrorPopup(new Error(result.stderr)) } await this._refreshRepository(repository) - console.log(`[UI] Success! ${remoteBranchName} updated cleanly.`) }, { backgroundTask: isBackgroundTask, } ) + } + + try { + await this.withPushPullFetch(repository, execFetchFn) + } catch (error) { + const errorWithMetadata = new ErrorWithMetadata(error, { + repository, + }) + this.emitError(errorWithMetadata) } finally { console.log(`[UI] Stopping spinner for ${remoteBranchName}.`) } 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 9c87d87e5e6..5eb8811dcca 100644 --- a/app/src/ui/branches/branch-list-item-context-menu.tsx +++ b/app/src/ui/branches/branch-list-item-context-menu.tsx @@ -6,7 +6,6 @@ import { assertNever } from '../../lib/fatal-error' interface IBranchContextMenuConfig { name: string nameWithoutRemote: string - isDefault: boolean isLocal: boolean isCurrentBranch: boolean repoType: RepoType | undefined @@ -16,7 +15,7 @@ interface IBranchContextMenuConfig { onViewPullRequestOnGitHub?: () => void onSetAsDefaultBranch?: (branchName: string) => void onDeleteBranch?: (branchName: string) => void - onFetchRemoteBranch?: (branchName: string) => void + onFetchRemoteOrLocalBranch?: (branchName: string) => void } export function generateBranchContextMenuItems( @@ -26,7 +25,6 @@ export function generateBranchContextMenuItems( name, nameWithoutRemote, isLocal, - isDefault, isCurrentBranch, repoType, isInUseByOtherWorktree, @@ -35,7 +33,7 @@ export function generateBranchContextMenuItems( onViewPullRequestOnGitHub, onSetAsDefaultBranch, onDeleteBranch, - onFetchRemoteBranch, + onFetchRemoteOrLocalBranch, } = config const items = new Array() if (onRenameBranch !== undefined) { @@ -73,11 +71,11 @@ export function generateBranchContextMenuItems( } // This should be the selected branch. - if (!isDefault && !isCurrentBranch && onFetchRemoteBranch !== undefined) { + if (!isCurrentBranch && onFetchRemoteOrLocalBranch !== undefined) { items.push({ type: 'separator' }) items.push({ label: getRemoteFetchBranchLabel(), - action: () => onFetchRemoteBranch(name), + action: () => onFetchRemoteOrLocalBranch(name), enabled: true, }) } diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index 4983a7c5f6c..5fd2c16ad26 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 onFetchRemoteBranch?: (branchName: string) => void + readonly onFetchRemoteOrLocalBranch?: (branchName: string) => void } /** The Branches list component. */ @@ -241,7 +241,7 @@ export class BranchList extends React.Component { onRenameBranch, onDeleteBranch, onSetAsDefaultBranch, - onFetchRemoteBranch, + onFetchRemoteOrLocalBranch, } = this.props if ( @@ -255,12 +255,10 @@ export class BranchList extends React.Component { const { type, name, nameWithoutRemote } = item.branch const isLocal = type === BranchType.Local const isInUseByOtherWorktree = !!this.inUseByOtherWorktreeName(item) - const isDefault = nameWithoutRemote === this.props.repository.defaultBranch const items = generateBranchContextMenuItems({ name, nameWithoutRemote, isLocal, - isDefault, isCurrentBranch: item.branch.name === this.props.currentBranch?.name, repoType: this.props.repository.gitHubRepository?.type, isInUseByOtherWorktree, @@ -270,7 +268,7 @@ export class BranchList extends React.Component { ? undefined : onSetAsDefaultBranch, onDeleteBranch, - onFetchRemoteBranch, + onFetchRemoteOrLocalBranch, }) showContextualMenu(items) diff --git a/app/src/ui/branches/branches-container.tsx b/app/src/ui/branches/branches-container.tsx index cc7351b0020..12003954b18 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 onFetchRemoteBranch: (branchName: string) => void + readonly onFetchRemoteOrLocalBranch: (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} - onFetchRemoteBranch={this.props.onFetchRemoteBranch} + onFetchRemoteOrLocalBranch={this.props.onFetchRemoteOrLocalBranch} /> ) case BranchesTab.PullRequests: { diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index de51c8e3129..693a45722fb 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 fetchRemoteBranch( + public fetchRemoteOrLocalBranch( repository: Repository, branch: Branch ): Promise { - return this.appStore._fetchRemoteBranch(repository, branch) + return this.appStore._fetchRemoteOrLocalBranch(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 ac1529fe0f4..fbff158fcac 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} - onFetchRemoteBranch={this.onFetchRemoteBranch} + onFetchRemoteOrLocalBranch={this.onFetchRemoteOrLocalBranch} onRenameBranch={this.onRenameBranch} onSetAsDefaultBranch={this.onSetAsDefaultBranch} underlineLinks={this.props.underlineLinks} @@ -317,12 +317,10 @@ export class BranchDropdown extends React.Component { } const { name, type, nameWithoutRemote } = tip.branch - const isDefault = nameWithoutRemote === this.props.repository.defaultBranch const items = generateBranchContextMenuItems({ name, nameWithoutRemote, isLocal: type === BranchType.Local, - isDefault, isCurrentBranch: true, repoType: this.props.repository.gitHubRepository?.type, isInUseByOtherWorktree: false, @@ -335,7 +333,10 @@ export class BranchDropdown extends React.Component { onViewPullRequestOnGitHub: this.props.currentPullRequest ? this.onViewPullRequestOnGithub : undefined, - onSetAsDefaultBranch: isDefault ? undefined : this.onSetAsDefaultBranch, + onSetAsDefaultBranch: + nameWithoutRemote === this.props.repository.defaultBranch + ? undefined + : this.onSetAsDefaultBranch, onDeleteBranch: this.onDeleteBranch, }) @@ -444,18 +445,16 @@ export class BranchDropdown extends React.Component { }) } - private onFetchRemoteBranch = (branchName: string) => { + private onFetchRemoteOrLocalBranch = (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) + this.props.dispatcher.fetchRemoteOrLocalBranch( + this.props.repository, + branch + ) } private onBadgeClick = () => { From 029203eed01a0311b1d16889b88046c9a200c818 Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Mon, 8 Jun 2026 15:39:20 +0600 Subject: [PATCH 07/12] Refactor fetch progress with to update UI --- app/src/lib/stores/app-store.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 630c084c181..5ea059a5c8b 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -6112,11 +6112,13 @@ export class AppStore extends TypedBaseStore { // repository.url const remote = { name: remoteName, url: 'file://' } - const progressCb = (progress: any) => { - console.log(progress, ' progress ') + const progressCb = (progress: IFetchProgress) => { + this.updatePushPullFetchProgress(repository, progress) } - const title = `Fetching ${remoteName}` + const progressTitle = isRemote + ? `Fetching ${branch.name}` + : `Fetching ${remoteBranchName}` const kind = 'fetch' let opts: IGitStringExecutionOptions = { successExitCodes: new Set([0]), @@ -6132,11 +6134,11 @@ export class AppStore extends TypedBaseStore { { ...opts, trackLFSProgress: true, isBackgroundTask }, new FetchProgressParser(), progress => { + console.log(progress, ' progress_log ') // 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. - console.log(progress, ' progress ') if (progress.kind === 'context') { if (!progress.text.startsWith('remote: Counting objects')) { return @@ -6149,7 +6151,7 @@ export class AppStore extends TypedBaseStore { progressCb({ kind, - title, + title: progressTitle, description, value, remote: remote.name, @@ -6157,15 +6159,6 @@ export class AppStore extends TypedBaseStore { } ) - // Initial progress - progressCb({ - kind, - title, - value: 0, - remote: remote.name, - }) - console.log(`[UI] Starting background update for: ${remoteBranchName}...`) - const fetchFn = async ( isRemote: boolean, opts: IGitStringExecutionOptions = {} @@ -6202,6 +6195,13 @@ export class AppStore extends TypedBaseStore { } const execFetchFn = async () => { + // Initial progress + progressCb({ + kind, + title: progressTitle, + value: 0, + remote: remote.name, + }) await gitStore.performFailableOperation( async () => { const result = await fetchFn(isRemote, opts) @@ -6232,7 +6232,7 @@ export class AppStore extends TypedBaseStore { }) this.emitError(errorWithMetadata) } finally { - console.log(`[UI] Stopping spinner for ${remoteBranchName}.`) + this.updatePushPullFetchProgress(repository, null) } } From 4ba92cbf0b5c3112b7c1293058bfd836ad73c37c Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Mon, 8 Jun 2026 18:51:32 +0600 Subject: [PATCH 08/12] Implement fetch progress and concurrent request handling - Add RemoteOrLocalBranchFetchProgressParser with appropriate weights - Move executionOptionsWithProgress setup inside fetchFn for cleaner scope - Filter both Counting and Compressing objects context lines in progress callback - Add onRequestAlreadyInProgress callback to withPushPullFetch for user feedback - Show error popup when fetch is blocked by ongoing push/pull/fetch - Enable --show-forced-updates flag for local fetch operations --- app/src/lib/progress/fetch.ts | 21 +++++ app/src/lib/stores/app-store.ts | 142 +++++++++++++++++--------------- 2 files changed, 96 insertions(+), 67 deletions(-) diff --git a/app/src/lib/progress/fetch.ts b/app/src/lib/progress/fetch.ts index e90e543c0a6..3c098fe7a78 100644 --- a/app/src/lib/progress/fetch.ts +++ b/app/src/lib/progress/fetch.ts @@ -20,3 +20,24 @@ export class FetchProgressParser extends GitProgressParser { super(steps) } } + +/** + * Progress steps for a single-branch fetch operation. Unlike a full fetch, + * Highly approximate (some would say outright inaccurate) division + * of the individual progress reporting steps in a fetch operation + */ +const remoteOrLocalBranchFetchSteps = [ + { title: 'remote: Counting objects', weight: 0.4 }, + { title: 'remote: Compressing objects', weight: 0.6 }, +] + +/** + * A utility class for interpreting the output from + * `git fetch --progress ` and turning that into a percentage + * value estimating the overall progress of a single branch fetch. + */ +export class RemoteOrLocalBranchFetchProgressParser extends GitProgressParser { + public constructor() { + super(remoteOrLocalBranchFetchSteps) + } +} diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 5ea059a5c8b..21c4b650a40 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -452,7 +452,10 @@ import { gatherCommitContext, } from '../copilot-conflict-context' import { resolveWithin } from '../path' -import { executionOptionsWithProgress, FetchProgressParser } from '../progress' +import { + executionOptionsWithProgress, + RemoteOrLocalBranchFetchProgressParser, +} from '../progress' import { envForRemoteOperation } from '../git/environment' const LastSelectedRepositoryIDKey = 'last-selected-repository-id' @@ -6033,12 +6036,13 @@ export class AppStore extends TypedBaseStore { private async withPushPullFetch( repository: Repository, - fn: () => Promise + fn: () => Promise, + onRequestAlreadyInProgress?: () => void ): Promise { const state = this.repositoryStateCache.get(repository) // Don't allow concurrent network operations. if (state.isPushPullFetchInProgress) { - return + return onRequestAlreadyInProgress?.() } this.repositoryStateCache.update(repository, () => ({ @@ -6047,8 +6051,10 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() try { + console.log('isPushPullFetchInProgress true') await fn() } finally { + console.log('isPushPullFetchInProgress false') this.repositoryStateCache.update(repository, () => ({ isPushPullFetchInProgress: false, })) @@ -6115,60 +6121,54 @@ export class AppStore extends TypedBaseStore { const progressCb = (progress: IFetchProgress) => { this.updatePushPullFetchProgress(repository, progress) } - const progressTitle = isRemote ? `Fetching ${branch.name}` : `Fetching ${remoteBranchName}` 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 => { - console.log(progress, ' progress_log ') - // 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 - - progressCb({ - kind, - title: progressTitle, - description, - value, - remote: remote.name, - }) + const fetchFn = async (isRemote: boolean): Promise => { + 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 RemoteOrLocalBranchFetchProgressParser(), + progress => { + if (progress.kind === 'context') { + const text = progress.text + if ( + !text.startsWith('remote: Counting objects') && + !text.startsWith('remote: Compressing objects') + ) { + return + } + } + + const description = + progress.kind === 'progress' ? progress.details.text : progress.text + const value = progress.percent - const fetchFn = async ( - isRemote: boolean, - opts: IGitStringExecutionOptions = {} - ): Promise => { + progressCb({ + kind, + title: progressTitle, + description, + value, + remote: remote.name, + }) + } + ) const flags = isRemote ? ['fetch', '--progress', '--recurse-submodules=on-demand', remoteName] : [ 'fetch', '--progress', - // '--show-forced-updates', + '--show-forced-updates', // '--no-write-fetch-head', '--recurse-submodules=on-demand', remoteName, @@ -6202,37 +6202,45 @@ export class AppStore extends TypedBaseStore { value: 0, remote: remote.name, }) - await gitStore.performFailableOperation( - async () => { - const result = await fetchFn(isRemote, opts) - if ( - !isRemote && - result && - (result.stderr?.includes('rejected') || - result.stderr?.includes('non-fast-forward')) - ) { - this.emitError( - new ErrorWithMetadata(new Error(result.stderr), { repository }) - ) - } + try { + await gitStore.performFailableOperation( + async () => { + const result = await fetchFn(isRemote) + if ( + !isRemote && + result && + (result.stderr?.includes('rejected') || + result.stderr?.includes('non-fast-forward')) + ) { + this.emitError( + new ErrorWithMetadata(new Error(result.stderr), { repository }) + ) + } - await this._refreshRepository(repository) - }, - { - backgroundTask: isBackgroundTask, - } - ) + await this._refreshRepository(repository) + }, + { + backgroundTask: isBackgroundTask, + } + ) + } finally { + this.updatePushPullFetchProgress(repository, null) + } } try { - await this.withPushPullFetch(repository, execFetchFn) + await this.withPushPullFetch(repository, execFetchFn, () => { + this.popupManager.addErrorPopup( + new Error( + 'Another push/pull/fetch request is in progress.\nTry again after the ongoing request is finished' + ) + ) + }) } catch (error) { const errorWithMetadata = new ErrorWithMetadata(error, { repository, }) this.emitError(errorWithMetadata) - } finally { - this.updatePushPullFetchProgress(repository, null) } } From 3e1bd82f9f8a618049907e38d960ebb780fed030 Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Mon, 8 Jun 2026 19:03:44 +0600 Subject: [PATCH 09/12] Refactor: Rename remoteOrLocal to single I think fetchSingleBranch is more appropriate than fetchRemoteOrLocalBranch --- app/src/lib/progress/fetch.ts | 6 +++--- app/src/lib/stores/app-store.ts | 10 +++++----- .../ui/branches/branch-list-item-context-menu.tsx | 12 ++++++------ app/src/ui/branches/branch-list.tsx | 6 +++--- app/src/ui/branches/branches-container.tsx | 4 ++-- app/src/ui/dispatcher/dispatcher.ts | 4 ++-- app/src/ui/toolbar/branch-dropdown.tsx | 9 +++------ 7 files changed, 24 insertions(+), 27 deletions(-) diff --git a/app/src/lib/progress/fetch.ts b/app/src/lib/progress/fetch.ts index 3c098fe7a78..5f22263cee8 100644 --- a/app/src/lib/progress/fetch.ts +++ b/app/src/lib/progress/fetch.ts @@ -26,7 +26,7 @@ export class FetchProgressParser extends GitProgressParser { * Highly approximate (some would say outright inaccurate) division * of the individual progress reporting steps in a fetch operation */ -const remoteOrLocalBranchFetchSteps = [ +const singleBranchFetchSteps = [ { title: 'remote: Counting objects', weight: 0.4 }, { title: 'remote: Compressing objects', weight: 0.6 }, ] @@ -36,8 +36,8 @@ const remoteOrLocalBranchFetchSteps = [ * `git fetch --progress ` and turning that into a percentage * value estimating the overall progress of a single branch fetch. */ -export class RemoteOrLocalBranchFetchProgressParser extends GitProgressParser { +export class SingleBranchFetchProgressParser extends GitProgressParser { public constructor() { - super(remoteOrLocalBranchFetchSteps) + super(singleBranchFetchSteps) } } diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 21c4b650a40..264ea2907d3 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -454,7 +454,7 @@ import { import { resolveWithin } from '../path' import { executionOptionsWithProgress, - RemoteOrLocalBranchFetchProgressParser, + SingleBranchFetchProgressParser, } from '../progress' import { envForRemoteOperation } from '../git/environment' @@ -6084,17 +6084,17 @@ export class AppStore extends TypedBaseStore { }) } - public async _fetchRemoteOrLocalBranch( + public async _fetchSingleBranch( repository: Repository, branch: Branch ): Promise { return this.withRefreshedGitHubRepository(repository, repo => { - return this.performFetchRemoteOrLocalBranch(repo, branch) + return this.performFetchSingleBranch(repo, branch) }) } /** This shouldn't be called directly. See `Dispatcher`. */ - private async performFetchRemoteOrLocalBranch( + private async performFetchSingleBranch( repository: Repository, branch: Branch ) { @@ -6138,7 +6138,7 @@ export class AppStore extends TypedBaseStore { } opts = await executionOptionsWithProgress( { ...opts, trackLFSProgress: true, isBackgroundTask }, - new RemoteOrLocalBranchFetchProgressParser(), + new SingleBranchFetchProgressParser(), progress => { if (progress.kind === 'context') { const text = progress.text 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 5eb8811dcca..d9ef012cded 100644 --- a/app/src/ui/branches/branch-list-item-context-menu.tsx +++ b/app/src/ui/branches/branch-list-item-context-menu.tsx @@ -15,7 +15,7 @@ interface IBranchContextMenuConfig { onViewPullRequestOnGitHub?: () => void onSetAsDefaultBranch?: (branchName: string) => void onDeleteBranch?: (branchName: string) => void - onFetchRemoteOrLocalBranch?: (branchName: string) => void + onFetchSingleBranch?: (branchName: string) => void } export function generateBranchContextMenuItems( @@ -33,7 +33,7 @@ export function generateBranchContextMenuItems( onViewPullRequestOnGitHub, onSetAsDefaultBranch, onDeleteBranch, - onFetchRemoteOrLocalBranch, + onFetchSingleBranch, } = config const items = new Array() if (onRenameBranch !== undefined) { @@ -71,11 +71,11 @@ export function generateBranchContextMenuItems( } // This should be the selected branch. - if (!isCurrentBranch && onFetchRemoteOrLocalBranch !== undefined) { + if (!isCurrentBranch && onFetchSingleBranch !== undefined) { items.push({ type: 'separator' }) items.push({ - label: getRemoteFetchBranchLabel(), - action: () => onFetchRemoteOrLocalBranch(name), + label: getSingleFetchBranchLabel(), + action: () => onFetchSingleBranch(name), enabled: true, }) } @@ -118,6 +118,6 @@ function getViewPullRequestLabel(repoType: RepoType): string { } } -function getRemoteFetchBranchLabel(): string { +function getSingleFetchBranchLabel(): string { return __DARWIN__ ? 'Fetch Branch' : 'Fetch branch' } diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index 5fd2c16ad26..ba7b8ad1e09 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 onFetchRemoteOrLocalBranch?: (branchName: string) => void + readonly onFetchSingleBranch?: (branchName: string) => void } /** The Branches list component. */ @@ -241,7 +241,7 @@ export class BranchList extends React.Component { onRenameBranch, onDeleteBranch, onSetAsDefaultBranch, - onFetchRemoteOrLocalBranch, + onFetchSingleBranch, } = this.props if ( @@ -268,7 +268,7 @@ export class BranchList extends React.Component { ? undefined : onSetAsDefaultBranch, onDeleteBranch, - onFetchRemoteOrLocalBranch, + onFetchSingleBranch, }) showContextualMenu(items) diff --git a/app/src/ui/branches/branches-container.tsx b/app/src/ui/branches/branches-container.tsx index 12003954b18..c4546d3d9a4 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 onFetchRemoteOrLocalBranch: (branchName: string) => void + readonly onFetchSingleBranch: (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} - onFetchRemoteOrLocalBranch={this.props.onFetchRemoteOrLocalBranch} + onFetchSingleBranch={this.props.onFetchSingleBranch} /> ) case BranchesTab.PullRequests: { diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 693a45722fb..62c02f93072 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 fetchRemoteOrLocalBranch( + public fetchSingleBranch( repository: Repository, branch: Branch ): Promise { - return this.appStore._fetchRemoteOrLocalBranch(repository, branch) + return this.appStore._fetchSingleBranch(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 fbff158fcac..a2ad71d6c6e 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} - onFetchRemoteOrLocalBranch={this.onFetchRemoteOrLocalBranch} + onFetchSingleBranch={this.onFetchSingleBranch} onRenameBranch={this.onRenameBranch} onSetAsDefaultBranch={this.onSetAsDefaultBranch} underlineLinks={this.props.underlineLinks} @@ -445,16 +445,13 @@ export class BranchDropdown extends React.Component { }) } - private onFetchRemoteOrLocalBranch = (branchName: string) => { + private onFetchSingleBranch = (branchName: string) => { const branch = this.getBranchWithName(branchName) if (!branch) { return } - this.props.dispatcher.fetchRemoteOrLocalBranch( - this.props.repository, - branch - ) + this.props.dispatcher.fetchSingleBranch(this.props.repository, branch) } private onBadgeClick = () => { From e0701233944a20d0c61aadeae23d4d4d2cc7473d Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Mon, 8 Jun 2026 19:27:39 +0600 Subject: [PATCH 10/12] Refactor: Improve concurrent popup logic. - Remove onRequestAlreadyInProgress callback from withPushPullFetch - Give instant feedback to user about concurrrent request - Remove debug console logs --- app/src/lib/stores/app-store.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 264ea2907d3..6495f0dd2e1 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -6036,13 +6036,12 @@ export class AppStore extends TypedBaseStore { private async withPushPullFetch( repository: Repository, - fn: () => Promise, - onRequestAlreadyInProgress?: () => void + fn: () => Promise ): Promise { const state = this.repositoryStateCache.get(repository) // Don't allow concurrent network operations. if (state.isPushPullFetchInProgress) { - return onRequestAlreadyInProgress?.() + return } this.repositoryStateCache.update(repository, () => ({ @@ -6051,10 +6050,8 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() try { - console.log('isPushPullFetchInProgress true') await fn() } finally { - console.log('isPushPullFetchInProgress false') this.repositoryStateCache.update(repository, () => ({ isPushPullFetchInProgress: false, })) @@ -6088,6 +6085,18 @@ export class AppStore extends TypedBaseStore { repository: Repository, branch: Branch ): Promise { + const state = this.repositoryStateCache.get(repository) + // Don't allow concurrent network operations. + if (state.isPushPullFetchInProgress) { + this._showPopup({ + type: PopupType.Error, + error: new Error( + 'Another push/pull/fetch request is in progress.\nTry again after the ongoing request is finished' + ), + }) + return + } + return this.withRefreshedGitHubRepository(repository, repo => { return this.performFetchSingleBranch(repo, branch) }) @@ -6229,13 +6238,7 @@ export class AppStore extends TypedBaseStore { } try { - await this.withPushPullFetch(repository, execFetchFn, () => { - this.popupManager.addErrorPopup( - new Error( - 'Another push/pull/fetch request is in progress.\nTry again after the ongoing request is finished' - ) - ) - }) + await this.withPushPullFetch(repository, execFetchFn) } catch (error) { const errorWithMetadata = new ErrorWithMetadata(error, { repository, From 3d17d1e7b7b41b41c734c7c76b21a325fd69a05d Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Mon, 8 Jun 2026 19:30:56 +0600 Subject: [PATCH 11/12] Refactor: Simplify single branch progress end logic --- app/src/lib/stores/app-store.ts | 43 ++++++++++++++++----------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 6495f0dd2e1..48389574244 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -6211,30 +6211,27 @@ export class AppStore extends TypedBaseStore { value: 0, remote: remote.name, }) - try { - await gitStore.performFailableOperation( - async () => { - const result = await fetchFn(isRemote) - if ( - !isRemote && - result && - (result.stderr?.includes('rejected') || - result.stderr?.includes('non-fast-forward')) - ) { - this.emitError( - new ErrorWithMetadata(new Error(result.stderr), { repository }) - ) - } - await this._refreshRepository(repository) - }, - { - backgroundTask: isBackgroundTask, + await gitStore.performFailableOperation( + async () => { + const result = await fetchFn(isRemote) + if ( + !isRemote && + result && + (result.stderr?.includes('rejected') || + result.stderr?.includes('non-fast-forward')) + ) { + this.emitError( + new ErrorWithMetadata(new Error(result.stderr), { repository }) + ) } - ) - } finally { - this.updatePushPullFetchProgress(repository, null) - } + + await this._refreshRepository(repository) + }, + { + backgroundTask: isBackgroundTask, + } + ) } try { @@ -6244,6 +6241,8 @@ export class AppStore extends TypedBaseStore { repository, }) this.emitError(errorWithMetadata) + } finally { + this.updatePushPullFetchProgress(repository, null) } } From 94996d89f17ad5173a3381d8c6034909be553d34 Mon Sep 17 00:00:00 2001 From: Ashfaq Naseem Date: Mon, 8 Jun 2026 20:47:33 +0600 Subject: [PATCH 12/12] Refactor: Update single branch fetch progress steps --- app/src/lib/progress/fetch.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/lib/progress/fetch.ts b/app/src/lib/progress/fetch.ts index 5f22263cee8..fcc7c2d14c6 100644 --- a/app/src/lib/progress/fetch.ts +++ b/app/src/lib/progress/fetch.ts @@ -27,8 +27,10 @@ export class FetchProgressParser extends GitProgressParser { * of the individual progress reporting steps in a fetch operation */ const singleBranchFetchSteps = [ - { title: 'remote: Counting objects', weight: 0.4 }, - { title: 'remote: Compressing objects', weight: 0.6 }, + { title: 'remote: Enumerating objects', weight: 0.1 }, + { title: 'remote: Counting objects', weight: 0.2 }, + { title: 'remote: Compressing objects', weight: 0.3 }, + { title: 'remote', weight: 0.4 }, ] /**