Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/src/lib/progress/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,26 @@ 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 singleBranchFetchSteps = [
{ 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 },
]

/**
* A utility class for interpreting the output from
* `git fetch --progress <remote> <branch>` and turning that into a percentage
* value estimating the overall progress of a single branch fetch.
*/
export class SingleBranchFetchProgressParser extends GitProgressParser {
public constructor() {
super(singleBranchFetchSteps)
}
}
172 changes: 172 additions & 0 deletions app/src/lib/stores/app-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ import {
listWorktrees,
unstageAll,
git,
IGitStringExecutionOptions,
IGitStringResult,
} from '../git'
import {
installGlobalLFSFilters,
Expand Down Expand Up @@ -450,6 +452,11 @@ import {
gatherCommitContext,
} from '../copilot-conflict-context'
import { resolveWithin } from '../path'
import {
executionOptionsWithProgress,
SingleBranchFetchProgressParser,
} from '../progress'
import { envForRemoteOperation } from '../git/environment'

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

Expand Down Expand Up @@ -6074,6 +6081,171 @@ export class AppStore extends TypedBaseStore<IAppState> {
})
}

public async _fetchSingleBranch(
repository: Repository,
branch: Branch
): Promise<void> {
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)
})
}

/** This shouldn't be called directly. See `Dispatcher`. */
private async performFetchSingleBranch(
repository: Repository,
branch: Branch
) {
const isRemote = branch.type === BranchType.Remote

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)

// repository.url
const remote = { name: remoteName, url: 'file://' }

const progressCb = (progress: IFetchProgress) => {
this.updatePushPullFetchProgress(repository, progress)
}
const progressTitle = isRemote
? `Fetching ${branch.name}`
: `Fetching ${remoteBranchName}`
const kind = 'fetch'

const fetchFn = async (isRemote: boolean): Promise<IGitStringResult> => {
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 SingleBranchFetchProgressParser(),
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

progressCb({
kind,
title: progressTitle,
description,
value,
remote: remote.name,
})
}
)
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]),
}

return await git(
[...flags, branchTarget],
repository.path,
actionName,
executionOpts
)
}

const execFetchFn = async () => {
// Initial progress
progressCb({
kind,
title: progressTitle,
value: 0,
remote: remote.name,
})

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,
}
)
}

try {
await this.withPushPullFetch(repository, execFetchFn)
} catch (error) {
const errorWithMetadata = new ErrorWithMetadata(error, {
repository,
})
this.emitError(errorWithMetadata)
} finally {
this.updatePushPullFetchProgress(repository, null)
}
}

public async _resetHardToUpstream(repository: Repository): Promise<void> {
const { branchesState } = this.repositoryStateCache.get(repository)
const { tip } = branchesState
Expand Down
19 changes: 18 additions & 1 deletion app/src/ui/branches/branch-list-item-context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ interface IBranchContextMenuConfig {
name: string
nameWithoutRemote: string
isLocal: boolean
isCurrentBranch: boolean
repoType: RepoType | undefined
isInUseByOtherWorktree: boolean
onRenameBranch?: (branchName: string) => void
onViewBranchOnGitHub?: () => void
onViewPullRequestOnGitHub?: () => void
onSetAsDefaultBranch?: (branchName: string) => void
onDeleteBranch?: (branchName: string) => void
onFetchSingleBranch?: (branchName: string) => void
}

export function generateBranchContextMenuItems(
Expand All @@ -23,16 +25,17 @@ export function generateBranchContextMenuItems(
name,
nameWithoutRemote,
isLocal,
isCurrentBranch,
repoType,
isInUseByOtherWorktree,
onRenameBranch,
onViewBranchOnGitHub,
onViewPullRequestOnGitHub,
onSetAsDefaultBranch,
onDeleteBranch,
onFetchSingleBranch,
} = config
const items = new Array<IMenuItem>()

if (onRenameBranch !== undefined) {
items.push({
label: 'Rename…',
Expand Down Expand Up @@ -67,6 +70,16 @@ export function generateBranchContextMenuItems(
})
}

// This should be the selected branch.
if (!isCurrentBranch && onFetchSingleBranch !== undefined) {
items.push({ type: 'separator' })
items.push({
label: getSingleFetchBranchLabel(),
action: () => onFetchSingleBranch(name),
enabled: true,
})
}

if (onDeleteBranch !== undefined && !isInUseByOtherWorktree) {
items.push({ type: 'separator' })
items.push({
Expand Down Expand Up @@ -104,3 +117,7 @@ function getViewPullRequestLabel(repoType: RepoType): string {
return assertNever(repoType, `Unknown repo type: ${repoType}`)
}
}

function getSingleFetchBranchLabel(): string {
return __DARWIN__ ? 'Fetch Branch' : 'Fetch branch'
}
13 changes: 11 additions & 2 deletions app/src/ui/branches/branch-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 onFetchSingleBranch?: (branchName: string) => void
}

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

const { onRenameBranch, onDeleteBranch, onSetAsDefaultBranch } = this.props
const {
onRenameBranch,
onDeleteBranch,
onSetAsDefaultBranch,
onFetchSingleBranch,
} = this.props

if (
onRenameBranch === undefined &&
Expand All @@ -247,11 +255,11 @@ export class BranchList extends React.Component<IBranchListProps> {
const { type, name, nameWithoutRemote } = item.branch
const isLocal = type === BranchType.Local
const isInUseByOtherWorktree = !!this.inUseByOtherWorktreeName(item)

const items = generateBranchContextMenuItems({
name,
nameWithoutRemote,
isLocal,
isCurrentBranch: item.branch.name === this.props.currentBranch?.name,
repoType: this.props.repository.gitHubRepository?.type,
isInUseByOtherWorktree,
onRenameBranch,
Expand All @@ -260,6 +268,7 @@ export class BranchList extends React.Component<IBranchListProps> {
? undefined
: onSetAsDefaultBranch,
onDeleteBranch,
onFetchSingleBranch,
})

showContextualMenu(items)
Expand Down
2 changes: 2 additions & 0 deletions app/src/ui/branches/branches-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ interface IBranchesContainerProps {
readonly onRenameBranch: (branchName: string) => void
readonly onSetAsDefaultBranch: (branchName: string) => void
readonly onDeleteBranch: (branchName: string) => void
readonly onFetchSingleBranch: (branchName: string) => void

readonly branchSortOrder: BranchSortOrder

Expand Down Expand Up @@ -293,6 +294,7 @@ export class BranchesContainer extends React.Component<
onRenameBranch={this.props.onRenameBranch}
onSetAsDefaultBranch={this.props.onSetAsDefaultBranch}
onDeleteBranch={this.props.onDeleteBranch}
onFetchSingleBranch={this.props.onFetchSingleBranch}
/>
)
case BranchesTab.PullRequests: {
Expand Down
8 changes: 8 additions & 0 deletions app/src/ui/dispatcher/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,14 @@ export class Dispatcher {
return this.appStore._pull(repository)
}

/** Pull remote branch by name */
public fetchSingleBranch(
repository: Repository,
branch: Branch
): Promise<void> {
return this.appStore._fetchSingleBranch(repository, branch)
}

public async pullAllRepositories(): Promise<void> {
try {
await this.appStore._pullAllRepositories()
Expand Down
11 changes: 11 additions & 0 deletions app/src/ui/toolbar/branch-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
branchSortOrder={this.props.branchSortOrder}
emoji={this.props.emoji}
onDeleteBranch={this.onDeleteBranch}
onFetchSingleBranch={this.onFetchSingleBranch}
onRenameBranch={this.onRenameBranch}
onSetAsDefaultBranch={this.onSetAsDefaultBranch}
underlineLinks={this.props.underlineLinks}
Expand Down Expand Up @@ -320,6 +321,7 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
name,
nameWithoutRemote,
isLocal: type === BranchType.Local,
isCurrentBranch: true,
repoType: this.props.repository.gitHubRepository?.type,
isInUseByOtherWorktree: false,
onRenameBranch: this.onRenameBranch,
Expand Down Expand Up @@ -443,6 +445,15 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
})
}

private onFetchSingleBranch = (branchName: string) => {
const branch = this.getBranchWithName(branchName)
if (!branch) {
return
}

this.props.dispatcher.fetchSingleBranch(this.props.repository, branch)
}

private onBadgeClick = () => {
// The badge can't be clicked while the CI status popover is shown, because
// in that case the Popover component will recognize the "click outside"
Expand Down
Loading