diff --git a/app/src/lib/progress/fetch.ts b/app/src/lib/progress/fetch.ts index e90e543c0a6..fcc7c2d14c6 100644 --- a/app/src/lib/progress/fetch.ts +++ b/app/src/lib/progress/fetch.ts @@ -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 ` 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) + } +} diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index aeefcde6c22..48389574244 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -265,6 +265,8 @@ import { listWorktrees, unstageAll, git, + IGitStringExecutionOptions, + IGitStringResult, } from '../git' import { installGlobalLFSFilters, @@ -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' @@ -6074,6 +6081,171 @@ export class AppStore extends TypedBaseStore { }) } + public async _fetchSingleBranch( + 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) + }) + } + + /** 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 => { + 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 { 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..d9ef012cded 100644 --- a/app/src/ui/branches/branch-list-item-context-menu.tsx +++ b/app/src/ui/branches/branch-list-item-context-menu.tsx @@ -7,6 +7,7 @@ interface IBranchContextMenuConfig { name: string nameWithoutRemote: string isLocal: boolean + isCurrentBranch: boolean repoType: RepoType | undefined isInUseByOtherWorktree: boolean onRenameBranch?: (branchName: string) => void @@ -14,6 +15,7 @@ interface IBranchContextMenuConfig { onViewPullRequestOnGitHub?: () => void onSetAsDefaultBranch?: (branchName: string) => void onDeleteBranch?: (branchName: string) => void + onFetchSingleBranch?: (branchName: string) => void } export function generateBranchContextMenuItems( @@ -23,6 +25,7 @@ export function generateBranchContextMenuItems( name, nameWithoutRemote, isLocal, + isCurrentBranch, repoType, isInUseByOtherWorktree, onRenameBranch, @@ -30,9 +33,9 @@ export function generateBranchContextMenuItems( onViewPullRequestOnGitHub, onSetAsDefaultBranch, onDeleteBranch, + onFetchSingleBranch, } = config const items = new Array() - if (onRenameBranch !== undefined) { items.push({ label: 'Rename…', @@ -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({ @@ -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' +} diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index 91eac3bab23..ba7b8ad1e09 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 onFetchSingleBranch?: (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, + onFetchSingleBranch, + } = this.props if ( onRenameBranch === undefined && @@ -247,11 +255,11 @@ export class BranchList extends React.Component { 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, @@ -260,6 +268,7 @@ export class BranchList extends React.Component { ? undefined : onSetAsDefaultBranch, onDeleteBranch, + onFetchSingleBranch, }) showContextualMenu(items) diff --git a/app/src/ui/branches/branches-container.tsx b/app/src/ui/branches/branches-container.tsx index 9bb9e46898e..c4546d3d9a4 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 onFetchSingleBranch: (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} + onFetchSingleBranch={this.props.onFetchSingleBranch} /> ) case BranchesTab.PullRequests: { diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 8a4e53bfee9..62c02f93072 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 fetchSingleBranch( + repository: Repository, + branch: Branch + ): Promise { + return this.appStore._fetchSingleBranch(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..a2ad71d6c6e 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} + onFetchSingleBranch={this.onFetchSingleBranch} onRenameBranch={this.onRenameBranch} onSetAsDefaultBranch={this.onSetAsDefaultBranch} underlineLinks={this.props.underlineLinks} @@ -320,6 +321,7 @@ export class BranchDropdown extends React.Component { name, nameWithoutRemote, isLocal: type === BranchType.Local, + isCurrentBranch: true, repoType: this.props.repository.gitHubRepository?.type, isInUseByOtherWorktree: false, onRenameBranch: this.onRenameBranch, @@ -443,6 +445,15 @@ export class BranchDropdown extends React.Component { }) } + 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"