Skip to content

Commit 7ac1793

Browse files
authored
Merge pull request #176 from anaseeem/feature/pull-remote-branch
Add option to fetch others/remote branches from the branch dropdown context menu
2 parents 0372d00 + 94996d8 commit 7ac1793

7 files changed

Lines changed: 245 additions & 3 deletions

File tree

app/src/lib/progress/fetch.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,26 @@ export class FetchProgressParser extends GitProgressParser {
2020
super(steps)
2121
}
2222
}
23+
24+
/**
25+
* Progress steps for a single-branch fetch operation. Unlike a full fetch,
26+
* Highly approximate (some would say outright inaccurate) division
27+
* of the individual progress reporting steps in a fetch operation
28+
*/
29+
const singleBranchFetchSteps = [
30+
{ title: 'remote: Enumerating objects', weight: 0.1 },
31+
{ title: 'remote: Counting objects', weight: 0.2 },
32+
{ title: 'remote: Compressing objects', weight: 0.3 },
33+
{ title: 'remote', weight: 0.4 },
34+
]
35+
36+
/**
37+
* A utility class for interpreting the output from
38+
* `git fetch --progress <remote> <branch>` and turning that into a percentage
39+
* value estimating the overall progress of a single branch fetch.
40+
*/
41+
export class SingleBranchFetchProgressParser extends GitProgressParser {
42+
public constructor() {
43+
super(singleBranchFetchSteps)
44+
}
45+
}

app/src/lib/stores/app-store.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,8 @@ import {
266266
listWorktrees,
267267
unstageAll,
268268
git,
269+
IGitStringExecutionOptions,
270+
IGitStringResult,
269271
} from '../git'
270272
import {
271273
installGlobalLFSFilters,
@@ -451,6 +453,11 @@ import {
451453
gatherCommitContext,
452454
} from '../copilot-conflict-context'
453455
import { resolveWithin } from '../path'
456+
import {
457+
executionOptionsWithProgress,
458+
SingleBranchFetchProgressParser,
459+
} from '../progress'
460+
import { envForRemoteOperation } from '../git/environment'
454461

455462
const LastSelectedRepositoryIDKey = 'last-selected-repository-id'
456463

@@ -6075,6 +6082,171 @@ export class AppStore extends TypedBaseStore<IAppState> {
60756082
})
60766083
}
60776084

6085+
public async _fetchSingleBranch(
6086+
repository: Repository,
6087+
branch: Branch
6088+
): Promise<void> {
6089+
const state = this.repositoryStateCache.get(repository)
6090+
// Don't allow concurrent network operations.
6091+
if (state.isPushPullFetchInProgress) {
6092+
this._showPopup({
6093+
type: PopupType.Error,
6094+
error: new Error(
6095+
'Another push/pull/fetch request is in progress.\nTry again after the ongoing request is finished'
6096+
),
6097+
})
6098+
return
6099+
}
6100+
6101+
return this.withRefreshedGitHubRepository(repository, repo => {
6102+
return this.performFetchSingleBranch(repo, branch)
6103+
})
6104+
}
6105+
6106+
/** This shouldn't be called directly. See `Dispatcher`. */
6107+
private async performFetchSingleBranch(
6108+
repository: Repository,
6109+
branch: Branch
6110+
) {
6111+
const isRemote = branch.type === BranchType.Remote
6112+
6113+
const remoteName = isRemote ? branch.remoteName : branch.upstreamRemoteName
6114+
const remoteBranchName = isRemote
6115+
? branch.nameWithoutRemote
6116+
: branch.upstreamWithoutRemote
6117+
6118+
if (!remoteName) {
6119+
throw new Error('Remote name not found')
6120+
}
6121+
if (!remoteBranchName) {
6122+
throw new Error('Remote branch not found')
6123+
}
6124+
6125+
const isBackgroundTask = false
6126+
const gitStore = this.gitStoreCache.get(repository)
6127+
6128+
// repository.url
6129+
const remote = { name: remoteName, url: 'file://' }
6130+
6131+
const progressCb = (progress: IFetchProgress) => {
6132+
this.updatePushPullFetchProgress(repository, progress)
6133+
}
6134+
const progressTitle = isRemote
6135+
? `Fetching ${branch.name}`
6136+
: `Fetching ${remoteBranchName}`
6137+
const kind = 'fetch'
6138+
6139+
const fetchFn = async (isRemote: boolean): Promise<IGitStringResult> => {
6140+
let opts: IGitStringExecutionOptions = {
6141+
successExitCodes: new Set([0]),
6142+
}
6143+
if (remote.url) {
6144+
opts = {
6145+
...opts,
6146+
env: await envForRemoteOperation(remote.url),
6147+
}
6148+
}
6149+
opts = await executionOptionsWithProgress(
6150+
{ ...opts, trackLFSProgress: true, isBackgroundTask },
6151+
new SingleBranchFetchProgressParser(),
6152+
progress => {
6153+
if (progress.kind === 'context') {
6154+
const text = progress.text
6155+
if (
6156+
!text.startsWith('remote: Counting objects') &&
6157+
!text.startsWith('remote: Compressing objects')
6158+
) {
6159+
return
6160+
}
6161+
}
6162+
6163+
const description =
6164+
progress.kind === 'progress' ? progress.details.text : progress.text
6165+
const value = progress.percent
6166+
6167+
progressCb({
6168+
kind,
6169+
title: progressTitle,
6170+
description,
6171+
value,
6172+
remote: remote.name,
6173+
})
6174+
}
6175+
)
6176+
const flags = isRemote
6177+
? ['fetch', '--progress', '--recurse-submodules=on-demand', remoteName]
6178+
: [
6179+
'fetch',
6180+
'--progress',
6181+
'--show-forced-updates',
6182+
// '--no-write-fetch-head',
6183+
'--recurse-submodules=on-demand',
6184+
remoteName,
6185+
]
6186+
6187+
const branchTarget = isRemote
6188+
? remoteBranchName
6189+
: `${remoteBranchName}:${remoteBranchName}`
6190+
const actionName = isRemote ? 'fetchRemoteBranch' : 'fetchLocalBranch'
6191+
6192+
const executionOpts = isRemote
6193+
? opts
6194+
: {
6195+
...opts,
6196+
successExitCodes: new Set([0, 1]),
6197+
}
6198+
6199+
return await git(
6200+
[...flags, branchTarget],
6201+
repository.path,
6202+
actionName,
6203+
executionOpts
6204+
)
6205+
}
6206+
6207+
const execFetchFn = async () => {
6208+
// Initial progress
6209+
progressCb({
6210+
kind,
6211+
title: progressTitle,
6212+
value: 0,
6213+
remote: remote.name,
6214+
})
6215+
6216+
await gitStore.performFailableOperation(
6217+
async () => {
6218+
const result = await fetchFn(isRemote)
6219+
if (
6220+
!isRemote &&
6221+
result &&
6222+
(result.stderr?.includes('rejected') ||
6223+
result.stderr?.includes('non-fast-forward'))
6224+
) {
6225+
this.emitError(
6226+
new ErrorWithMetadata(new Error(result.stderr), { repository })
6227+
)
6228+
}
6229+
6230+
await this._refreshRepository(repository)
6231+
},
6232+
{
6233+
backgroundTask: isBackgroundTask,
6234+
}
6235+
)
6236+
}
6237+
6238+
try {
6239+
await this.withPushPullFetch(repository, execFetchFn)
6240+
} catch (error) {
6241+
const errorWithMetadata = new ErrorWithMetadata(error, {
6242+
repository,
6243+
})
6244+
this.emitError(errorWithMetadata)
6245+
} finally {
6246+
this.updatePushPullFetchProgress(repository, null)
6247+
}
6248+
}
6249+
60786250
public async _resetHardToUpstream(repository: Repository): Promise<void> {
60796251
const { branchesState } = this.repositoryStateCache.get(repository)
60806252
const { tip } = branchesState

app/src/ui/branches/branch-list-item-context-menu.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ interface IBranchContextMenuConfig {
77
name: string
88
nameWithoutRemote: string
99
isLocal: boolean
10+
isCurrentBranch: boolean
1011
repoType: RepoType | undefined
1112
isInUseByOtherWorktree: boolean
1213
onRenameBranch?: (branchName: string) => void
1314
onViewBranchOnGitHub?: () => void
1415
onViewPullRequestOnGitHub?: () => void
1516
onSetAsDefaultBranch?: (branchName: string) => void
1617
onDeleteBranch?: (branchName: string) => void
18+
onFetchSingleBranch?: (branchName: string) => void
1719
}
1820

1921
export function generateBranchContextMenuItems(
@@ -23,16 +25,17 @@ export function generateBranchContextMenuItems(
2325
name,
2426
nameWithoutRemote,
2527
isLocal,
28+
isCurrentBranch,
2629
repoType,
2730
isInUseByOtherWorktree,
2831
onRenameBranch,
2932
onViewBranchOnGitHub,
3033
onViewPullRequestOnGitHub,
3134
onSetAsDefaultBranch,
3235
onDeleteBranch,
36+
onFetchSingleBranch,
3337
} = config
3438
const items = new Array<IMenuItem>()
35-
3639
if (onRenameBranch !== undefined) {
3740
items.push({
3841
label: 'Rename…',
@@ -67,6 +70,16 @@ export function generateBranchContextMenuItems(
6770
})
6871
}
6972

73+
// This should be the selected branch.
74+
if (!isCurrentBranch && onFetchSingleBranch !== undefined) {
75+
items.push({ type: 'separator' })
76+
items.push({
77+
label: getSingleFetchBranchLabel(),
78+
action: () => onFetchSingleBranch(name),
79+
enabled: true,
80+
})
81+
}
82+
7083
if (onDeleteBranch !== undefined && !isInUseByOtherWorktree) {
7184
items.push({ type: 'separator' })
7285
items.push({
@@ -104,3 +117,7 @@ function getViewPullRequestLabel(repoType: RepoType): string {
104117
return assertNever(repoType, `Unknown repo type: ${repoType}`)
105118
}
106119
}
120+
121+
function getSingleFetchBranchLabel(): string {
122+
return __DARWIN__ ? 'Fetch Branch' : 'Fetch branch'
123+
}

app/src/ui/branches/branch-list.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ interface IBranchListProps {
144144

145145
/** Optional: Callback for if delete context menu should exist */
146146
readonly onDeleteBranch?: (branchName: string) => void
147+
148+
/** Optional: Callback if pull option for remote branch context menu should exist */
149+
readonly onFetchSingleBranch?: (branchName: string) => void
147150
}
148151

149152
/** The Branches list component. */
@@ -234,7 +237,12 @@ export class BranchList extends React.Component<IBranchListProps> {
234237
) => {
235238
event.preventDefault()
236239

237-
const { onRenameBranch, onDeleteBranch, onSetAsDefaultBranch } = this.props
240+
const {
241+
onRenameBranch,
242+
onDeleteBranch,
243+
onSetAsDefaultBranch,
244+
onFetchSingleBranch,
245+
} = this.props
238246

239247
if (
240248
onRenameBranch === undefined &&
@@ -247,11 +255,11 @@ export class BranchList extends React.Component<IBranchListProps> {
247255
const { type, name, nameWithoutRemote } = item.branch
248256
const isLocal = type === BranchType.Local
249257
const isInUseByOtherWorktree = !!this.inUseByOtherWorktreeName(item)
250-
251258
const items = generateBranchContextMenuItems({
252259
name,
253260
nameWithoutRemote,
254261
isLocal,
262+
isCurrentBranch: item.branch.name === this.props.currentBranch?.name,
255263
repoType: this.props.repository.gitHubRepository?.type,
256264
isInUseByOtherWorktree,
257265
onRenameBranch,
@@ -260,6 +268,7 @@ export class BranchList extends React.Component<IBranchListProps> {
260268
? undefined
261269
: onSetAsDefaultBranch,
262270
onDeleteBranch,
271+
onFetchSingleBranch,
263272
})
264273

265274
showContextualMenu(items)

app/src/ui/branches/branches-container.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ interface IBranchesContainerProps {
5353
readonly onRenameBranch: (branchName: string) => void
5454
readonly onSetAsDefaultBranch: (branchName: string) => void
5555
readonly onDeleteBranch: (branchName: string) => void
56+
readonly onFetchSingleBranch: (branchName: string) => void
5657

5758
readonly branchSortOrder: BranchSortOrder
5859

@@ -293,6 +294,7 @@ export class BranchesContainer extends React.Component<
293294
onRenameBranch={this.props.onRenameBranch}
294295
onSetAsDefaultBranch={this.props.onSetAsDefaultBranch}
295296
onDeleteBranch={this.props.onDeleteBranch}
297+
onFetchSingleBranch={this.props.onFetchSingleBranch}
296298
/>
297299
)
298300
case BranchesTab.PullRequests: {

app/src/ui/dispatcher/dispatcher.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,14 @@ export class Dispatcher {
821821
return this.appStore._pull(repository)
822822
}
823823

824+
/** Pull remote branch by name */
825+
public fetchSingleBranch(
826+
repository: Repository,
827+
branch: Branch
828+
): Promise<void> {
829+
return this.appStore._fetchSingleBranch(repository, branch)
830+
}
831+
824832
public async pullAllRepositories(): Promise<void> {
825833
try {
826834
await this.appStore._pullAllRepositories()

app/src/ui/toolbar/branch-dropdown.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
118118
branchSortOrder={this.props.branchSortOrder}
119119
emoji={this.props.emoji}
120120
onDeleteBranch={this.onDeleteBranch}
121+
onFetchSingleBranch={this.onFetchSingleBranch}
121122
onRenameBranch={this.onRenameBranch}
122123
onSetAsDefaultBranch={this.onSetAsDefaultBranch}
123124
underlineLinks={this.props.underlineLinks}
@@ -320,6 +321,7 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
320321
name,
321322
nameWithoutRemote,
322323
isLocal: type === BranchType.Local,
324+
isCurrentBranch: true,
323325
repoType: this.props.repository.gitHubRepository?.type,
324326
isInUseByOtherWorktree: false,
325327
onRenameBranch: this.onRenameBranch,
@@ -443,6 +445,15 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
443445
})
444446
}
445447

448+
private onFetchSingleBranch = (branchName: string) => {
449+
const branch = this.getBranchWithName(branchName)
450+
if (!branch) {
451+
return
452+
}
453+
454+
this.props.dispatcher.fetchSingleBranch(this.props.repository, branch)
455+
}
456+
446457
private onBadgeClick = () => {
447458
// The badge can't be clicked while the CI status popover is shown, because
448459
// in that case the Popover component will recognize the "click outside"

0 commit comments

Comments
 (0)