Merge branch 'development' into releases/2.6.6

This commit is contained in:
Sergio Padrino 2021-03-10 03:08:24 -08:00 committed by GitHub
commit e534559871
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 2998 additions and 290 deletions

View file

@ -1,8 +1,8 @@
# [GitHub Desktop](https://desktop.github.com)
GitHub Desktop is an open source [Electron](https://electron.atom.io)-based
GitHub Desktop is an open source [Electron](https://www.electronjs.org/)-based
GitHub app. It is written in [TypeScript](http://www.typescriptlang.org) and
uses [React](https://facebook.github.io/react/).
uses [React](https://reactjs.org/).
![GitHub Desktop screenshot - Windows](https://cloud.githubusercontent.com/assets/359239/26094502/a1f56d02-3a5d-11e7-8799-23c7ba5e5106.png)

View file

@ -20,6 +20,7 @@ import {
Progress,
ICheckoutProgress,
ICloneProgress,
ICherryPickProgress,
} from '../models/progress'
import { Popup } from '../models/popup'
@ -37,6 +38,7 @@ import { RebaseFlowStep } from '../models/rebase-flow-step'
import { IStashEntry } from '../models/stash-entry'
import { TutorialStep } from '../models/tutorial-step'
import { UncommittedChangesStrategy } from '../models/uncommitted-changes-strategy'
import { CherryPickFlowStep } from '../models/cherry-pick'
export enum SelectionType {
Repository,
@ -249,6 +251,11 @@ export interface IAppState {
* Whether or not the app should use spell check on commit summary and description
*/
readonly commitSpellcheckEnabled: boolean
/**
* Whether or not the user has been introduced to the cherry pick feature
*/
readonly hasShownCherryPickIntro: boolean
}
export enum FoldoutType {
@ -351,13 +358,16 @@ export function isRebaseConflictState(
}
/**
* Conflicts can occur during a rebase or a merge.
* Conflicts can occur during a rebase, merge, or cherry pick.
*
* Callers should inspect the `kind` field to determine the kind of conflict
* that is occurring, as this will then provide additional information specific
* to the conflict, to help with resolving the issue.
*/
export type ConflictState = MergeConflictState | RebaseConflictState
export type ConflictState =
| MergeConflictState
| RebaseConflictState
| CherryPickConflictState
export interface IRepositoryState {
readonly commitSelection: ICommitSelection
@ -430,6 +440,9 @@ export interface IRepositoryState {
readonly revertProgress: IRevertProgress | null
readonly localTags: Map<string, string> | null
/** State associated with a cherry pick being performed */
readonly cherryPickState: ICherryPickState
}
export interface IBranchesState {
@ -522,8 +535,8 @@ export interface IRebaseState {
}
export interface ICommitSelection {
/** The commit currently selected in the app */
readonly sha: string | null
/** The commits currently selected in the app */
readonly shas: ReadonlyArray<string>
/** The list of files associated with the current commit */
readonly changedFiles: ReadonlyArray<CommittedFileChange>
@ -724,3 +737,59 @@ export interface ICompareToBranch {
* An action to send to the application store to update the compare state
*/
export type CompareAction = IViewHistory | ICompareToBranch
/** State associated with a cherry pick being performed on a repository */
export interface ICherryPickState {
/**
* The current step of the flow the user should see.
*
* `null` indicates that there is no cherry pick underway.
*/
readonly step: CherryPickFlowStep | null
/**
* The underlying Git information associated with the current cherry pick
*
* This will be set to `null` when no target branch has been selected to
* initiate the rebase.
*/
readonly progress: ICherryPickProgress | null
/**
* Whether the user has done work to resolve any conflicts as part of this
* cherry pick.
*/
readonly userHasResolvedConflicts: boolean
/**
* The sha of the target branch tip before cherry pick initiated.
*
* This will be set to null if no cherry pick has been initiated.
*/
readonly targetBranchUndoSha: string | null
}
/**
* Stores information about a cherry pick conflict when it occurs
*/
export type CherryPickConflictState = {
readonly kind: 'cherryPick'
/**
* Manual resolutions chosen by the user for conflicted files to be applied
* before continuing the cherry pick.
*/
readonly manualResolutions: Map<string, ManualConflictResolution>
/**
* The branch chosen by the user to copy the cherry picked commits to
*/
readonly targetBranchName: string
}
/** Guard function for checking conflicts are from a rebase */
export function isCherryPickConflictState(
conflictStatus: ConflictState
): conflictStatus is CherryPickConflictState {
return conflictStatus.kind === 'cherryPick'
}

View file

@ -153,5 +153,5 @@ export function enableUnhandledRejectionReporting(): boolean {
* Should we allow cherry picking
*/
export function enableCherryPicking(): boolean {
return false // enableBetaFeatures()
return enableDevelopmentFeatures()
}

View file

@ -17,6 +17,8 @@ import { ChildProcess } from 'child_process'
import { round } from '../../ui/lib/round'
import byline from 'byline'
import { ICherryPickSnapshot } from '../../models/cherry-pick'
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
import { stageManualConflictResolution } from './stage'
/** The app-specific results from attempting to cherry pick commits*/
export enum CherryPickResult {
@ -36,11 +38,13 @@ export enum CherryPickResult {
*/
OutstandingFilesNotStaged = 'OutstandingFilesNotStaged',
/**
* The cherry pick was not attempted because it could not check the status of
* the repository. The caller needs to confirm the repository is in a usable
* state.
* The cherry pick was not attempted:
* - it could not check the status of the repository.
* - there was an invalid revision range provided.
* - there were uncommitted changes present.
* - there were errors in checkout the target branch
*/
Aborted = 'Aborted',
UnableToStart = 'UnableToStart',
/**
* An unexpected error as part of the cherry pick flow was caught and handled.
*
@ -149,7 +153,7 @@ export async function cherryPick(
`Unable to cherry pick these branches
because one or both of the refs do not exist in the repository`
)
return CherryPickResult.Error
return CherryPickResult.UnableToStart
}
baseOptions = await configureOptionsWithCallBack(
@ -159,8 +163,12 @@ export async function cherryPick(
)
}
// --keep-redundant-commits follows pattern of making sure someone cherry
// picked commit summaries appear in target branch history even tho they may
// be empty. This flag also results in the ability to cherry pick empty
// commits (thus, --allow-empty is not required.)
const result = await git(
['cherry-pick', revisionRange],
['cherry-pick', revisionRange, '--keep-redundant-commits'],
repository.path,
'cherry pick',
baseOptions
@ -203,8 +211,7 @@ function parseCherryPickResult(result: IGitResult): CherryPickResult {
export async function getCherryPickSnapshot(
repository: Repository
): Promise<ICherryPickSnapshot | null> {
const cherryPickHead = readCherryPickHead(repository)
if (cherryPickHead === null) {
if (!isCherryPickHeadFound(repository)) {
// If there no cherry pick head, there is no cherry pick in progress.
return null
}
@ -276,6 +283,7 @@ export async function getCherryPickSnapshot(
}
const count = commits.length - remainingShas.length
const commitSummaryIndex = count > 0 ? count - 1 : 0
return {
progress: {
kind: 'cherryPick',
@ -283,9 +291,11 @@ export async function getCherryPickSnapshot(
value: round(count / commits.length, 2),
cherryPickCommitCount: count,
totalCommitCount: commits.length,
currentCommitSummary: commits[count - 1].summary ?? '',
currentCommitSummary: commits[commitSummaryIndex].summary ?? '',
},
remainingCommits: commits.slice(count, commits.length),
commits,
targetBranchUndoSha: firstSha,
}
}
@ -303,13 +313,28 @@ export async function getCherryPickSnapshot(
export async function continueCherryPick(
repository: Repository,
files: ReadonlyArray<WorkingDirectoryFileChange>,
manualResolutions: ReadonlyMap<string, ManualConflictResolution> = new Map(),
progressCallback?: (progress: ICherryPickProgress) => void
): Promise<CherryPickResult> {
// only stage files related to cherry pick
const trackedFiles = files.filter(f => {
return f.status.kind !== AppFileStatusKind.Untracked
})
await stageFiles(repository, trackedFiles)
// apply conflict resolutions
for (const [path, resolution] of manualResolutions) {
const file = files.find(f => f.path === path)
if (file === undefined) {
log.error(
`[continueCherryPick] couldn't find file ${path} even though there's a manual resolution for it`
)
continue
}
await stageManualConflictResolution(repository, file, resolution)
}
const otherFiles = trackedFiles.filter(f => !manualResolutions.has(f.path))
await stageFiles(repository, otherFiles)
const status = await getStatus(repository)
if (status == null) {
@ -317,13 +342,12 @@ export async function continueCherryPick(
`[continueCherryPick] unable to get status after staging changes,
skipping any other steps`
)
return CherryPickResult.Aborted
return CherryPickResult.UnableToStart
}
// make sure cherry pick is still in progress to continue
const cherryPickCurrentCommit = await readCherryPickHead(repository)
if (cherryPickCurrentCommit === null) {
return CherryPickResult.Aborted
if (await !isCherryPickHeadFound(repository)) {
return CherryPickResult.UnableToStart
}
let options: IGitExecutionOptions = {
@ -343,7 +367,7 @@ export async function continueCherryPick(
log.warn(
`[continueCherryPick] unable to get cherry pick status, skipping other steps`
)
return CherryPickResult.Aborted
return CherryPickResult.UnableToStart
}
options = configureOptionsWithCallBack(
options,
@ -352,8 +376,33 @@ export async function continueCherryPick(
)
}
const trackedFilesAfter = status.workingDirectory.files.filter(
f => f.status.kind !== AppFileStatusKind.Untracked
)
if (trackedFilesAfter.length === 0) {
log.warn(
`[cherryPick] no tracked changes to commit, continuing cherry pick but skipping this commit`
)
// This commits the empty commit so that the cherry picked commit still
// shows up in the target branches history.
const result = await git(
['commit', '--allow-empty'],
repository.path,
'continueCherryPickSkipCurrentCommit',
options
)
return parseCherryPickResult(result)
}
// --keep-redundant-commits follows pattern of making sure someone cherry
// picked commit summaries appear in target branch history even tho they may
// be empty. This flag also results in the ability to cherry pick empty
// commits (thus, --allow-empty is not required.)
const result = await git(
['cherry-pick', '--continue'],
['cherry-pick', '--continue', '--keep-redundant-commits'],
repository.path,
'continueCherryPick',
options
@ -368,29 +417,24 @@ export async function abortCherryPick(repository: Repository) {
}
/**
* Attempt to read the `.git/CHERRY_PICK_HEAD` file inside a repository to confirm
* the cherry pick is still active.
* Check if the `.git/CHERRY_PICK_HEAD` file exists
*/
async function readCherryPickHead(
export async function isCherryPickHeadFound(
repository: Repository
): Promise<string | null> {
): Promise<boolean> {
try {
const cherryPickHead = Path.join(
const cherryPickHeadPath = Path.join(
repository.path,
'.git',
'CHERRY_PICK_HEAD'
)
const cherryPickCurrentCommitOutput = await FSE.readFile(
cherryPickHead,
'utf8'
)
return cherryPickCurrentCommitOutput.trim()
return FSE.pathExists(cherryPickHeadPath)
} catch (err) {
log.warn(
`[cherryPick] a problem was encountered reading .git/CHERRY_PICK_HEAD,
so it is unsafe to continue cherry picking`,
err
)
return null
return false
}
}

View file

@ -87,6 +87,7 @@ const imageFileExtensions = new Set([
'.ico',
'.webp',
'.bmp',
'.avif',
])
/**
@ -299,6 +300,9 @@ function getMediaType(extension: string) {
if (extension === '.bmp') {
return 'image/bmp'
}
if (extension === '.avif') {
return 'image/avif'
}
// fallback value as per the spec
return 'text/plain'

View file

@ -27,6 +27,7 @@ import { isMergeHeadSet } from './merge'
import { getBinaryPaths } from './diff'
import { getRebaseInternalState } from './rebase'
import { RebaseInternalState } from '../../models/rebase'
import { isCherryPickHeadFound } from './cherry-pick'
/**
* V8 has a limit on the size of string it can create (~256MB), and unless we want to
@ -61,6 +62,9 @@ export interface IStatusResult {
/** details about the rebase operation, if found */
readonly rebaseInternalState: RebaseInternalState | null
/** true if repository is in cherry pick state */
readonly isCherryPickingHeadFound: boolean
/** the absolute path to the repository's working directory */
readonly workingDirectory: WorkingDirectoryStatus
}
@ -229,6 +233,8 @@ export async function getStatus(
const workingDirectory = WorkingDirectoryStatus.fromFiles([...files.values()])
const isCherryPickingHeadFound = await isCherryPickHeadFound(repository)
return {
currentBranch,
currentTip,
@ -238,6 +244,7 @@ export async function getStatus(
mergeHeadFound,
rebaseInternalState,
workingDirectory,
isCherryPickingHeadFound,
}
}

View file

@ -60,6 +60,7 @@ import {
IFetchProgress,
IRevertProgress,
IRebaseProgress,
ICherryPickProgress,
} from '../../models/progress'
import { Popup, PopupType } from '../../models/popup'
import { IGitAccount } from '../../models/git-account'
@ -99,12 +100,15 @@ import {
RepositorySectionTab,
SelectionType,
MergeConflictState,
isMergeConflictState,
RebaseConflictState,
IRebaseState,
IRepositoryState,
ChangesSelectionKind,
ChangesWorkingDirectorySelection,
isRebaseConflictState,
isCherryPickConflictState,
isMergeConflictState,
CherryPickConflictState,
} from '../app-state'
import {
findEditorOrDefault,
@ -150,6 +154,9 @@ import {
deleteLocalBranch,
deleteRemoteBranch,
fastForwardBranches,
revRangeInclusive,
GitResetMode,
reset,
} from '../git'
import {
installGlobalLFSFilters,
@ -266,6 +273,18 @@ import {
getShowSideBySideDiff,
setShowSideBySideDiff,
} from '../../ui/lib/diff-mode'
import {
CherryPickFlowStep,
CherryPickStepKind,
} from '../../models/cherry-pick'
import {
abortCherryPick,
cherryPick,
CherryPickResult,
continueCherryPick,
getCherryPickSnapshot,
isCherryPickHeadFound,
} from '../git/cherry-pick'
const LastSelectedRepositoryIDKey = 'last-selected-repository-id'
@ -321,6 +340,8 @@ const InitialRepositoryIndicatorTimeout = 2 * 60 * 1000
const MaxInvalidFoldersToDisplay = 3
const hasShownCherryPickIntroKey = 'has-shown-cherry-pick-intro'
export class AppStore extends TypedBaseStore<IAppState> {
private readonly gitStoreCache: GitStoreCache
@ -421,6 +442,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
private currentOnboardingTutorialStep = TutorialStep.NotApplicable
private readonly tutorialAssessor: OnboardingTutorialAssessor
/**
* Whether or not the user has been introduced to the cherry pick feature
*/
private hasShownCherryPickIntro: boolean = false
public constructor(
private readonly gitHubUserStore: GitHubUserStore,
private readonly cloningRepositoriesStore: CloningRepositoriesStore,
@ -767,6 +793,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
currentOnboardingTutorialStep: this.currentOnboardingTutorialStep,
repositoryIndicatorsEnabled: this.repositoryIndicatorsEnabled,
commitSpellcheckEnabled: this.commitSpellcheckEnabled,
hasShownCherryPickIntro: this.hasShownCherryPickIntro,
}
}
@ -935,7 +962,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private clearSelectedCommit(repository: Repository) {
this.repositoryStateCache.updateCommitSelection(repository, () => ({
sha: null,
shas: [],
file: null,
changedFiles: [],
diff: null,
@ -945,16 +972,19 @@ export class AppStore extends TypedBaseStore<IAppState> {
/** This shouldn't be called directly. See `Dispatcher`. */
public async _changeCommitSelection(
repository: Repository,
sha: string
shas: ReadonlyArray<string>
): Promise<void> {
const { commitSelection } = this.repositoryStateCache.get(repository)
if (commitSelection.sha === sha) {
if (
commitSelection.shas.length === shas.length &&
commitSelection.shas.every((sha, i) => sha === shas[i])
) {
return
}
this.repositoryStateCache.updateCommitSelection(repository, () => ({
sha,
shas,
file: null,
changedFiles: [],
diff: null,
@ -968,7 +998,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
commitSHAs: ReadonlyArray<string>
) {
const state = this.repositoryStateCache.get(repository)
let selectedSHA = state.commitSelection.sha
let selectedSHA =
state.commitSelection.shas.length === 1
? state.commitSelection.shas[0]
: null
if (selectedSHA != null) {
const index = commitSHAs.findIndex(sha => sha === selectedSHA)
if (index < 0) {
@ -979,8 +1012,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
}
if (selectedSHA == null && commitSHAs.length > 0) {
this._changeCommitSelection(repository, commitSHAs[0])
if (state.commitSelection.shas.length === 0 && commitSHAs.length > 0) {
this._changeCommitSelection(repository, [commitSHAs[0]])
this._loadChangedFilesForCurrentSelection(repository)
}
}
@ -1236,14 +1269,15 @@ export class AppStore extends TypedBaseStore<IAppState> {
): Promise<void> {
const state = this.repositoryStateCache.get(repository)
const { commitSelection } = state
const currentSHA = commitSelection.sha
if (currentSHA == null) {
const currentSHAs = commitSelection.shas
if (currentSHAs.length !== 1) {
// if none or multiple, we don't display a diff
return
}
const gitStore = this.gitStoreCache.get(repository)
const changedFiles = await gitStore.performFailableOperation(() =>
getChangedFiles(repository, currentSHA)
getChangedFiles(repository, currentSHAs[0])
)
if (!changedFiles) {
return
@ -1252,7 +1286,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
// The selection could have changed between when we started loading the
// changed files and we finished. We might wanna store the changed files per
// SHA/path.
if (currentSHA !== state.commitSelection.sha) {
if (
commitSelection.shas.length !== currentSHAs.length ||
commitSelection.shas[0] !== currentSHAs[0]
) {
return
}
@ -1297,9 +1334,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.emitUpdate()
const stateBeforeLoad = this.repositoryStateCache.get(repository)
const sha = stateBeforeLoad.commitSelection.sha
const shas = stateBeforeLoad.commitSelection.shas
if (!sha) {
if (shas.length === 0) {
if (__DEV__) {
throw new Error(
"No currently selected sha yet we've been asked to switch file selection"
@ -1309,19 +1346,22 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
}
// We do not get a diff when multiple commits selected
if (shas.length > 1) {
return
}
const diff = await getCommitDiff(
repository,
file,
sha,
shas[0],
this.hideWhitespaceInDiff
)
const stateAfterLoad = this.repositoryStateCache.get(repository)
const { shas: shasAfter } = stateAfterLoad.commitSelection
// A whole bunch of things could have happened since we initiated the diff load
if (
stateAfterLoad.commitSelection.sha !== stateBeforeLoad.commitSelection.sha
) {
if (shasAfter.length !== shas.length || shasAfter[0] !== shas[0]) {
return
}
if (!stateAfterLoad.commitSelection.file) {
@ -1712,6 +1752,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
})
this.hasShownCherryPickIntro = getBoolean(hasShownCherryPickIntroKey, false)
this.emitUpdateNow()
this.accountsStore.refresh()
@ -1881,6 +1923,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
}))
this.updateRebaseFlowConflictsIfFound(repository)
this.updateCherryPickFlowConflictsIfFound(repository)
if (this.selectedRepository === repository) {
this._triggerConflictsFlow(repository)
@ -1902,7 +1945,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
)
const { conflictState } = changesState
if (conflictState === null || isMergeConflictState(conflictState)) {
if (conflictState === null || !isRebaseConflictState(conflictState)) {
return
}
@ -1929,6 +1972,32 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
}
/**
* Push changes from latest conflicts into current cherry pick flow step, if needed
* - i.e. - multiple instance of running in to conflicts
*/
private updateCherryPickFlowConflictsIfFound(repository: Repository) {
const { changesState, cherryPickState } = this.repositoryStateCache.get(
repository
)
const { conflictState } = changesState
if (conflictState === null || !isCherryPickConflictState(conflictState)) {
return
}
const { step } = cherryPickState
if (step === null) {
return
}
if (step.kind === CherryPickStepKind.ShowConflicts) {
this.repositoryStateCache.updateCherryPickState(repository, () => ({
step: { ...step, conflictState },
}))
}
}
private async _triggerConflictsFlow(repository: Repository) {
const state = this.repositoryStateCache.get(repository)
const { conflictState } = state.changesState
@ -1938,10 +2007,12 @@ export class AppStore extends TypedBaseStore<IAppState> {
return
}
if (conflictState.kind === 'merge') {
if (isMergeConflictState(conflictState)) {
await this.showMergeConflictsDialog(repository, conflictState)
} else if (conflictState.kind === 'rebase') {
} else if (isRebaseConflictState(conflictState)) {
await this.showRebaseConflictsDialog(repository, conflictState)
} else if (isCherryPickConflictState(conflictState)) {
await this.showCherryPickConflictsDialog(repository, conflictState)
} else {
assertNever(conflictState, `Unsupported conflict kind`)
}
@ -3977,7 +4048,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
const { commitSelection } = this.repositoryStateCache.get(repository)
if (commitSelection.sha === commit.sha) {
if (
commitSelection.shas.length > 0 &&
commitSelection.shas.find(sha => sha === commit.sha) !== undefined
) {
this.clearSelectedCommit(repository)
}
@ -5480,8 +5554,17 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
})
// update rebase flow state after choosing manual resolution
this.updateRebaseStateAfterManualResolution(repository)
this.updateCherryPickStateAfterManualResolution(repository)
this.emitUpdate()
}
/**
* Updates the rebase flow conflict step state as the manual resolutions
* have been changed.
*/
private updateRebaseStateAfterManualResolution(repository: Repository) {
const currentState = this.repositoryStateCache.get(repository)
const { changesState, rebaseState } = currentState
@ -5498,8 +5581,33 @@ export class AppStore extends TypedBaseStore<IAppState> {
step: { ...step, conflictState },
}))
}
}
this.emitUpdate()
/**
* Updates the cherry pick flow conflict step state as the manual resolutions
* have been changed.
*/
private updateCherryPickStateAfterManualResolution(
repository: Repository
): void {
const currentState = this.repositoryStateCache.get(repository)
const { changesState, cherryPickState } = currentState
const { conflictState } = changesState
const { step } = cherryPickState
if (
conflictState === null ||
step === null ||
!isCherryPickConflictState(conflictState) ||
step.kind !== CherryPickStepKind.ShowConflicts
) {
return
}
this.repositoryStateCache.updateCherryPickState(repository, () => ({
step: { ...step, conflictState },
}))
}
private async createStashAndDropPreviousEntry(
@ -5681,6 +5789,369 @@ export class AppStore extends TypedBaseStore<IAppState> {
this._closePopup(PopupType.CreateTutorialRepository)
}
}
/** This shouldn't be called directly. See `Dispatcher`. */
public async _setCherryPickFlowStep(
repository: Repository,
step: CherryPickFlowStep
): Promise<void> {
this.repositoryStateCache.updateCherryPickState(repository, () => ({
step,
}))
this.emitUpdate()
}
/** This shouldn't be called directly. See `Dispatcher`. */
public _initializeCherryPickProgress(
repository: Repository,
commits: ReadonlyArray<CommitOneLine>
) {
if (commits.length === 0) {
// This shouldn't happen... but in case throw error.
throw new Error(
'Unable to initialize cherry pick progress. No commits provided.'
)
}
this.repositoryStateCache.updateCherryPickState(repository, () => {
return {
progress: {
kind: 'cherryPick',
title: `Cherry picking commit 1 of ${commits.length} commits`,
value: 0,
cherryPickCommitCount: 1,
totalCommitCount: commits.length,
currentCommitSummary: commits[0].summary,
},
}
})
this.emitUpdate()
}
/** This shouldn't be called directly. See `Dispatcher`. */
public async _cherryPick(
repository: Repository,
targetBranch: Branch,
commits: ReadonlyArray<CommitOneLine>,
sourceBranch: Branch | null
): Promise<CherryPickResult> {
if (commits.length === 0) {
log.warn('[_cherryPick] - Unable to cherry pick. No commits provided.')
return CherryPickResult.UnableToStart
}
let result: CherryPickResult | null | undefined
result = this.checkForUncommittedChangesBeforeCherryPick(
repository,
targetBranch,
commits,
sourceBranch
)
if (result !== null) {
return result
}
result = await this.checkoutTargetBranchForCherryPick(
repository,
targetBranch
)
if (result !== null) {
return result
}
const progressCallback = (progress: ICherryPickProgress) => {
this.repositoryStateCache.updateCherryPickState(repository, () => ({
progress,
}))
this.emitUpdate()
}
let revisionRange: string
if (commits.length === 1) {
revisionRange = commits[0].sha
} else {
const earliestCommit = commits[commits.length - 1]
revisionRange = revRangeInclusive(earliestCommit.sha, commits[0].sha)
}
const gitStore = this.gitStoreCache.get(repository)
result = await gitStore.performFailableOperation(() =>
cherryPick(repository, revisionRange, progressCallback)
)
return result || CherryPickResult.Error
}
/**
* Checks for uncommitted changes before cherry pick
*
* If uncommitted changes exist, ask user to stash and return
* CherryPickResult.UnableToStart.
*
* If no uncommitted changes, return null.
*/
private checkForUncommittedChangesBeforeCherryPick(
repository: Repository,
targetBranch: Branch,
commits: ReadonlyArray<CommitOneLine>,
sourceBranch: Branch | null
): CherryPickResult | null {
const { changesState } = this.repositoryStateCache.get(repository)
const hasChanges = changesState.workingDirectory.files.length > 0
if (!hasChanges) {
return null
}
this._showPopup({
type: PopupType.LocalChangesOverwritten,
repository,
retryAction: {
type: RetryActionType.CherryPick,
repository,
targetBranch,
commits,
sourceBranch,
},
files: changesState.workingDirectory.files.map(f => f.path),
})
return CherryPickResult.UnableToStart
}
/**
* Attempts to checkout target branch of cherry pick operation
*
* If unable to checkout, return CherryPickResult.UnableToStart
* Otherwise, return null.
*/
private async checkoutTargetBranchForCherryPick(
repository: Repository,
targetBranch: Branch
): Promise<CherryPickResult | null> {
const gitStore = this.gitStoreCache.get(repository)
const checkoutSuccessful = await this.withAuthenticatingUser(
repository,
(r, account) => {
return gitStore.performFailableOperation(() =>
checkoutBranch(repository, account, targetBranch)
)
}
)
return checkoutSuccessful === true ? null : CherryPickResult.UnableToStart
}
/** This shouldn't be called directly. See `Dispatcher`. */
public async _abortCherryPick(
repository: Repository,
sourceBranch: Branch | null
): Promise<void> {
const gitStore = this.gitStoreCache.get(repository)
await gitStore.performFailableOperation(() => abortCherryPick(repository))
this.checkoutBranchIfNotNull(repository, sourceBranch)
}
/** This shouldn't be called directly. See `Dispatcher`. */
public _endCherryPickFlow(repository: Repository): void {
this.repositoryStateCache.updateCherryPickState(repository, () => ({
step: null,
progress: null,
userHasResolvedConflicts: false,
}))
this.emitUpdate()
}
/** This shouldn't be called directly. See `Dispatcher`. */
public _setCherryPickTargetBranchUndoSha(
repository: Repository,
sha: string
): void {
// An update is not emitted here because there is no need
// to trigger a re-render at this point. (storing for later)
this.repositoryStateCache.updateCherryPickState(repository, () => ({
targetBranchUndoSha: sha,
}))
}
/** This shouldn't be called directly. See `Dispatcher`. */
public _setCherryPickConflictsResolved(repository: Repository) {
// an update is not emitted here because there is no need
// to trigger a re-render at this point
this.repositoryStateCache.updateCherryPickState(repository, () => ({
userHasResolvedConflicts: true,
}))
}
/** This shouldn't be called directly. See `Dispatcher`. */
public async _continueCherryPick(
repository: Repository,
files: ReadonlyArray<WorkingDirectoryFileChange>,
manualResolutions: ReadonlyMap<string, ManualConflictResolution>
): Promise<CherryPickResult> {
const progressCallback = (progress: ICherryPickProgress) => {
this.repositoryStateCache.updateCherryPickState(repository, () => ({
progress,
}))
this.emitUpdate()
}
const gitStore = this.gitStoreCache.get(repository)
const result = await gitStore.performFailableOperation(() =>
continueCherryPick(repository, files, manualResolutions, progressCallback)
)
return result || CherryPickResult.Error
}
/** This shouldn't be called directly. See `Dispatcher`. */
public async _setCherryPickProgressFromState(repository: Repository) {
const snapshot = await getCherryPickSnapshot(repository)
if (snapshot === null) {
return
}
const { progress, targetBranchUndoSha } = snapshot
this.repositoryStateCache.updateCherryPickState(repository, () => ({
progress,
targetBranchUndoSha,
}))
}
/** display the cherry pick flow, if not already in this flow */
private async showCherryPickConflictsDialog(
repository: Repository,
conflictState: CherryPickConflictState
) {
const alreadyInFlow =
this.currentPopup !== null &&
this.currentPopup.type === PopupType.CherryPick
if (alreadyInFlow) {
return
}
const displayingBanner =
this.currentBanner !== null &&
this.currentBanner.type === BannerType.CherryPickConflictsFound
if (displayingBanner) {
return
}
await this._setCherryPickProgressFromState(repository)
this._setCherryPickFlowStep(repository, {
kind: CherryPickStepKind.ShowConflicts,
conflictState,
})
const snapshot = await getCherryPickSnapshot(repository)
if (snapshot === null) {
log.warn(
`[showCherryPickConflictsDialog] unable to get cherry pick status from git, unable to continue`
)
return
}
this._showPopup({
type: PopupType.CherryPick,
repository,
commits: snapshot?.commits,
sourceBranch: null,
})
}
/** This shouldn't be called directly. See `Dispatcher`. */
public _dismissCherryPickIntro() {
setBoolean(hasShownCherryPickIntroKey, true)
this.hasShownCherryPickIntro = true
this.emitUpdate()
}
/** This shouldn't be called directly. See `Dispatcher`. */
public async _clearCherryPickingHead(
repository: Repository,
sourceBranch: Branch | null
): Promise<void> {
if (!isCherryPickHeadFound(repository)) {
return
}
const gitStore = this.gitStoreCache.get(repository)
await gitStore.performFailableOperation(() => abortCherryPick(repository))
this.checkoutBranchIfNotNull(repository, sourceBranch)
return this._refreshRepository(repository)
}
/** This shouldn't be called directly. See `Dispatcher`. */
public async _undoCherryPick(
repository: Repository,
targetBranchName: string,
sourceBranch: Branch | null,
countCherryPicked: number
): Promise<void> {
const { branchesState } = this.repositoryStateCache.get(repository)
const { tip } = branchesState
if (tip.kind !== TipState.Valid || tip.branch.name !== targetBranchName) {
log.warn(
'[undoCherryPick] - Could not undo cherry pick. User no longer on target branch.'
)
return
}
const {
cherryPickState: { targetBranchUndoSha },
} = this.repositoryStateCache.get(repository)
if (targetBranchUndoSha === null) {
log.warn('[undoCherryPick] - Could not determine target branch undo sha')
return
}
const gitStore = this.gitStoreCache.get(repository)
await gitStore.performFailableOperation(() =>
reset(repository, GitResetMode.Hard, targetBranchUndoSha)
)
this.checkoutBranchIfNotNull(repository, sourceBranch)
const banner: Banner = {
type: BannerType.CherryPickUndone,
targetBranchName,
countCherryPicked,
}
this._setBanner(banner)
return this._refreshRepository(repository)
}
private async checkoutBranchIfNotNull(
repository: Repository,
sourceBranch: Branch | null
) {
if (sourceBranch === null) {
return
}
const gitStore = this.gitStoreCache.get(repository)
await this.withAuthenticatingUser(repository, async (r, account) => {
await gitStore.performFailableOperation(() =>
checkoutBranch(repository, account, sourceBranch)
)
})
}
}
/**

View file

@ -18,6 +18,7 @@ import {
ICommitSelection,
IRebaseState,
ChangesSelectionKind,
ICherryPickState,
} from '../app-state'
import { merge } from '../merge'
import { DefaultCommitMessage } from '../../models/commit-message'
@ -101,12 +102,23 @@ export class RepositoryStateCache {
return { rebaseState: newState }
})
}
public updateCherryPickState<K extends keyof ICherryPickState>(
repository: Repository,
fn: (state: ICherryPickState) => Pick<ICherryPickState, K>
) {
this.update(repository, state => {
const { cherryPickState } = state
const newState = merge(cherryPickState, fn(cherryPickState))
return { cherryPickState: newState }
})
}
}
function getInitialRepositoryState(): IRepositoryState {
return {
commitSelection: {
sha: null,
shas: [],
file: null,
changedFiles: new Array<CommittedFileChange>(),
diff: null,
@ -170,5 +182,11 @@ function getInitialRepositoryState(): IRepositoryState {
checkoutProgress: null,
pushPullFetchProgress: null,
revertProgress: null,
cherryPickState: {
step: null,
progress: null,
userHasResolvedConflicts: false,
targetBranchUndoSha: null,
},
}
}

View file

@ -157,6 +157,18 @@ function getConflictState(
}
}
if (status.isCherryPickingHeadFound) {
const { currentBranch: targetBranchName } = status
if (targetBranchName == null) {
return null
}
return {
kind: 'cherryPick',
manualResolutions,
targetBranchName,
}
}
return null
}

View file

@ -66,7 +66,7 @@ export class TrampolineServer {
this.server.listen(0, '127.0.0.1', async () => {
// Replace the error handler
this.server.removeAllListeners('error')
this.server.on('error', error => this.onError(error))
this.server.on('error', this.onError)
resolve()
})
@ -125,6 +125,8 @@ export class TrampolineServer {
socket.pipe(split2(/\0/)).on('data', data => {
this.onDataReceived(socket, parser, data)
})
socket.on('error', this.onError)
}
private onDataReceived(
@ -177,7 +179,7 @@ export class TrampolineServer {
}
}
private onError(error: Error) {
private onError = (error: Error) => {
sendNonFatalException('trampolineServer', error)
this.close()
}

View file

@ -6,6 +6,9 @@ export enum BannerType {
SuccessfulRebase = 'SuccessfulRebase',
RebaseConflictsFound = 'RebaseConflictsFound',
BranchAlreadyUpToDate = 'BranchAlreadyUpToDate',
SuccessfulCherryPick = 'SuccessfulCherryPick',
CherryPickConflictsFound = 'CherryPickConflictsFound',
CherryPickUndone = 'CherryPickUndone',
}
export type Banner =
@ -44,3 +47,26 @@ export type Banner =
/** name of the branch we merged into `ourBranch` */
readonly theirBranch?: string
}
| {
readonly type: BannerType.SuccessfulCherryPick
/** name of the branch that was cherry picked to */
readonly targetBranchName: string
/** number of commits cherry picked */
readonly countCherryPicked: number
/** callback to run when user clicks undo link in banner */
readonly onUndoCherryPick: () => void
}
| {
readonly type: BannerType.CherryPickConflictsFound
/** name of the branch that the commits are being cherry picked onto */
readonly targetBranchName: string
/** callback to run when user clicks on link in banner text */
readonly onOpenConflictsDialog: () => void
}
| {
readonly type: BannerType.CherryPickUndone
/** name of the branch that the commits were cherry picked onto */
readonly targetBranchName: string
/** number of commits cherry picked */
readonly countCherryPicked: number
}

View file

@ -1,3 +1,5 @@
import { CherryPickConflictState } from '../lib/app-state'
import { Branch } from './branch'
import { CommitOneLine } from './commit'
import { ICherryPickProgress } from './progress'
@ -5,6 +7,112 @@ import { ICherryPickProgress } from './progress'
export interface ICherryPickSnapshot {
/** The sequence of commits remaining to be cherry picked */
readonly remainingCommits: ReadonlyArray<CommitOneLine>
/** The sequence of commits being cherry picked */
readonly commits: ReadonlyArray<CommitOneLine>
/** The progress of the operation */
readonly progress: ICherryPickProgress
/** The sha of the target branch tip before cherry pick initiated. */
readonly targetBranchUndoSha: string
}
/** Union type representing the possible states of the cherry pick flow */
export type CherryPickFlowStep =
| ChooseTargetBranchesStep
| ShowProgressStep
| ShowConflictsStep
| CommitsChosenStep
| HideConflictsStep
| ConfirmAbortStep
export const enum CherryPickStepKind {
/**
* An initial state of a cherry pick.
*
* This is where the user has started dragging a commit or set of commits but
* has not yet dropped them on a branch or an area to launch to choose branch
* dialog.
*/
CommitsChosen = 'CommitsChosen',
/**
* An initial state of a cherry pick.
*
* This is where the user picks which is the target of the cherry pick.
* This step will be skipped when cherry pick is initiated through
* drag and drop onto a specific branch. But, it will be the first step
* if the cherry pick is initiated through the context menu.
*/
ChooseTargetBranch = 'ChooseTargetBranch',
/**
* After the user chooses the target branch of the cherry pick, the
* progress view shows the cherry pick is progressing.
*
* This should be the default view when there are no conflicts to address.
*/
ShowProgress = 'ShowProgress',
/**
* The cherry pick has encountered conflicts that need resolved. This will be
* shown as a list of files and the conflict state.
*
* Once the conflicts are resolved, the user can continue the cherry pick and
* the view will switch back to `ShowProgress`.
*/
ShowConflicts = 'ShowConflicts',
/**
* The user may wish to leave the conflict dialog and view the files in
* the Changes tab to get a better context. In this situation, the application
* will show a banner to indicate this context and help the user return to the
* conflicted list.
*/
HideConflicts = 'HideConflicts',
/**
* If the user attempts to abort the in-progress cherry pick and the user has
* resolved conflicts, the application should ask the user to confirm that
* they wish to abort.
*/
ConfirmAbort = 'ConfirmAbort',
}
/** Shape of data needed to choose the base branch for a cherry pick */
export type ChooseTargetBranchesStep = {
readonly kind: CherryPickStepKind.ChooseTargetBranch
readonly defaultBranch: Branch | null
readonly currentBranch: Branch
readonly allBranches: ReadonlyArray<Branch>
readonly recentBranches: ReadonlyArray<Branch>
}
/** Shape of data to show progress of the current cherry pick */
export type ShowProgressStep = {
readonly kind: CherryPickStepKind.ShowProgress
}
/** Shape of data to show conflicts that need to be resolved by the user */
export type ShowConflictsStep = {
readonly kind: CherryPickStepKind.ShowConflicts
conflictState: CherryPickConflictState
}
/** Shape of data to track when user hides conflicts dialog */
export type HideConflictsStep = {
readonly kind: CherryPickStepKind.HideConflicts
}
/**
* Shape of data for when a user has chosen commits to cherry pick but not yet
* selected a target branch.
*/
export type CommitsChosenStep = {
readonly kind: CherryPickStepKind.CommitsChosen
commits: ReadonlyArray<CommitOneLine>
}
/** Shape of data to use when confirming user should abort cherry pick */
export type ConfirmAbortStep = {
readonly kind: CherryPickStepKind.ConfirmAbort
readonly conflictState: CherryPickConflictState
}

View file

@ -10,7 +10,7 @@ import { IRemote } from './remote'
import { RetryAction } from './retry-actions'
import { WorkingDirectoryFileChange } from './status'
import { PreferencesTab } from './preferences'
import { ICommitContext } from './commit'
import { CommitOneLine, ICommitContext } from './commit'
import { IStashEntry } from './stash-entry'
import { Account } from '../models/account'
import { Progress } from './progress'
@ -273,5 +273,6 @@ export type Popup =
| {
type: PopupType.CherryPick
repository: Repository
commitSha: string
commits: ReadonlyArray<CommitOneLine>
sourceBranch: Branch | null
}

View file

@ -1,6 +1,7 @@
import { Repository } from './repository'
import { CloneOptions } from './clone-options'
import { Branch } from './branch'
import { CommitOneLine } from './commit'
/** The types of actions that can be retried. */
export enum RetryActionType {
@ -11,6 +12,7 @@ export enum RetryActionType {
Checkout,
Merge,
Rebase,
CherryPick,
}
/** The retriable actions and their associated data. */
@ -42,3 +44,10 @@ export type RetryAction =
baseBranch: Branch
targetBranch: Branch
}
| {
type: RetryActionType.CherryPick
repository: Repository
targetBranch: Branch
commits: ReadonlyArray<CommitOneLine>
sourceBranch: Branch | null
}

View file

@ -8,6 +8,9 @@ import {
FoldoutType,
SelectionType,
HistoryTabMode,
ICherryPickState,
isRebaseConflictState,
isCherryPickConflictState,
} from '../lib/app-state'
import { Dispatcher } from './dispatcher'
import { AppStore, GitHubUserStore, IssuesStore } from '../lib/stores'
@ -120,7 +123,14 @@ import { DiscardSelection } from './discard-changes/discard-selection-dialog'
import { LocalChangesOverwrittenDialog } from './local-changes-overwritten/local-changes-overwritten-dialog'
import memoizeOne from 'memoize-one'
import { AheadBehindStore } from '../lib/stores/ahead-behind-store'
import { CherryPickFlow } from './cherry-pick/cherry-pick-flow'
import {
CherryPickStepKind,
ChooseTargetBranchesStep,
} from '../models/cherry-pick'
import { getAccountForRepository } from '../lib/get-account-for-repository'
import { CommitOneLine } from '../models/commit'
import { WorkingDirectoryStatus } from '../models/status'
const MinuteInMilliseconds = 1000 * 60
const HourInMilliseconds = MinuteInMilliseconds * 60
@ -1991,9 +2001,47 @@ export class App extends React.Component<IAppProps, IAppState> {
files={popup.files}
/>
)
case PopupType.CherryPick:
// TODO: Create Cherry Pick Branch Dialog
return null
case PopupType.CherryPick: {
const cherryPickState = this.getCherryPickState()
const workingDirectory = this.getWorkingDirectory()
if (
cherryPickState === null ||
cherryPickState.step == null ||
workingDirectory === null
) {
log.warn(
`[App] Invalid state encountered:
cherry pick flow should not be active when step is null,
the selected app state is not a repository state,
or cannot obtain the working directory.`
)
return null
}
const { step, progress, userHasResolvedConflicts } = cherryPickState
return (
<CherryPickFlow
key="cherry-pick-flow"
repository={popup.repository}
dispatcher={this.props.dispatcher}
onDismissed={onPopupDismissedFn}
step={step}
emoji={this.state.emoji}
progress={progress}
commits={popup.commits}
openFileInExternalEditor={this.openFileInExternalEditor}
workingDirectory={workingDirectory}
userHasResolvedConflicts={userHasResolvedConflicts}
resolvedExternalEditor={this.state.resolvedExternalEditor}
openRepositoryInShell={this.openCurrentRepositoryInShell}
sourceBranch={popup.sourceBranch}
onShowCherryPickConflictsBanner={
this.onShowCherryPickConflictsBanner
}
/>
)
}
default:
return assertNever(popup, `Unknown popup type: ${popup}`)
}
@ -2026,9 +2074,9 @@ export class App extends React.Component<IAppProps, IAppState> {
)
const { conflictState } = changesState
if (conflictState === null || conflictState.kind === 'merge') {
if (conflictState === null || !isRebaseConflictState(conflictState)) {
log.debug(
`[App.onShowRebaseConflictsBanner] no conflict state found, ignoring...`
`[App.onShowRebaseConflictsBanner] no rebase conflict state found, ignoring...`
)
return
}
@ -2587,6 +2635,8 @@ export class App extends React.Component<IAppProps, IAppState> {
isShowingFoldout={this.state.currentFoldout !== null}
aheadBehindStore={this.props.aheadBehindStore}
commitSpellcheckEnabled={this.state.commitSpellcheckEnabled}
onCherryPick={this.startCherryPickWithoutBranch}
hasShownCherryPickIntro={this.state.hasShownCherryPickIntro}
/>
)
} else if (selectedState.type === SelectionType.CloningRepository) {
@ -2687,6 +2737,126 @@ export class App extends React.Component<IAppProps, IAppState> {
private isTutorialPaused() {
return this.state.currentOnboardingTutorialStep === TutorialStep.Paused
}
/**
* When starting cherry pick from context menu, we need to initialize the
* cherry pick state flow step with the ChooseTargetBranch as opposed
* to drag and drop which will start at the ShowProgress step.
*
* Step initialization must be done before and outside of the
* `currentPopupContent` method because it is a rendering method that is
* re-run on every update. It will just keep showing the step initialized
* there otherwise - not allowing for other flow steps.
*/
private startCherryPickWithoutBranch = (
repository: Repository,
commits: ReadonlyArray<CommitOneLine>
) => {
const repositoryState = this.props.repositoryStateManager.get(repository)
const {
defaultBranch,
allBranches,
recentBranches,
tip,
} = repositoryState.branchesState
let currentBranch: Branch | null = null
if (tip.kind === TipState.Valid) {
currentBranch = tip.branch
} else {
throw new Error(
'Tip is not in a valid state, which is required to start the cherry pick flow'
)
}
const initialStep: ChooseTargetBranchesStep = {
kind: CherryPickStepKind.ChooseTargetBranch,
defaultBranch,
currentBranch,
allBranches,
recentBranches,
}
this.props.dispatcher.setCherryPickFlowStep(repository, initialStep)
this.showPopup({
type: PopupType.CherryPick,
repository,
commits,
sourceBranch: currentBranch,
})
}
private getCherryPickState(): ICherryPickState | null {
const { selectedState } = this.state
if (
selectedState === null ||
selectedState.type !== SelectionType.Repository
) {
return null
}
const { cherryPickState } = selectedState.state
return cherryPickState
}
private onShowCherryPickConflictsBanner = (
repository: Repository,
targetBranchName: string,
sourceBranch: Branch | null,
commits: ReadonlyArray<CommitOneLine>
) => {
this.props.dispatcher.setCherryPickFlowStep(repository, {
kind: CherryPickStepKind.HideConflicts,
})
this.props.dispatcher.setBanner({
type: BannerType.CherryPickConflictsFound,
targetBranchName,
onOpenConflictsDialog: async () => {
const { changesState } = this.props.repositoryStateManager.get(
repository
)
const { conflictState } = changesState
if (
conflictState === null ||
!isCherryPickConflictState(conflictState)
) {
log.debug(
`[App.onShowCherryPickConflictsBanner] no cherry pick conflict state found, ignoring...`
)
return
}
await this.props.dispatcher.setCherryPickProgressFromState(repository)
this.props.dispatcher.setCherryPickFlowStep(repository, {
kind: CherryPickStepKind.ShowConflicts,
conflictState,
})
this.props.dispatcher.showPopup({
type: PopupType.CherryPick,
repository,
commits,
sourceBranch,
})
},
})
}
private getWorkingDirectory(): WorkingDirectoryStatus | null {
const { selectedState } = this.state
if (
selectedState === null ||
selectedState.type !== SelectionType.Repository
) {
return null
}
return selectedState.state.changesState.workingDirectory
}
}
function NoRepositorySelected() {

View file

@ -0,0 +1,48 @@
import * as React from 'react'
import { Octicon, OcticonSymbol } from '../octicons'
import { Banner } from './banner'
import { LinkButton } from '../lib/link-button'
interface ICherryPickConflictsBannerProps {
/** branch the user is rebasing into */
readonly targetBranchName: string
/** callback to fire when the dialog should be reopened */
readonly onOpenConflictsDialog: () => void
/** callback to fire to dismiss the banner */
readonly onDismissed: () => void
}
export class CherryPickConflictsBanner extends React.Component<
ICherryPickConflictsBannerProps,
{}
> {
private openDialog = async () => {
this.props.onDismissed()
this.props.onOpenConflictsDialog()
}
private onDismissed = () => {
log.warn(
`[CherryPickConflictsBanner] this is not dismissable by default unless the user clicks on the link`
)
}
public render() {
return (
<Banner
id="cherry-pick-conflicts-banner"
dismissable={false}
onDismissed={this.onDismissed}
>
<Octicon className="alert-icon" symbol={OcticonSymbol.alert} />
<div className="banner-message">
<span>
Resolve conflicts to continue cherry picking onto{' '}
<strong>{this.props.targetBranchName}</strong>.
</span>
<LinkButton onClick={this.openDialog}>View conflicts</LinkButton>
</div>
</Banner>
)
}
}

View file

@ -0,0 +1,33 @@
import * as React from 'react'
import { Octicon, OcticonSymbol } from '../octicons'
import { Banner } from './banner'
interface ICherryPickUndoneBannerProps {
readonly targetBranchName: string
readonly countCherryPicked: number
readonly onDismissed: () => void
}
export class CherryPickUndone extends React.Component<
ICherryPickUndoneBannerProps,
{}
> {
public render() {
const { countCherryPicked, targetBranchName, onDismissed } = this.props
const pluralized = countCherryPicked === 1 ? 'commit' : 'commits'
return (
<Banner id="cherry-pick-undone" timeout={5000} onDismissed={onDismissed}>
<div className="green-circle">
<Octicon className="check-icon" symbol={OcticonSymbol.check} />
</div>
<div className="banner-message">
<span>
Cherry pick undone. Successfully removed the {countCherryPicked}
{' copied '}
{pluralized} from <strong>{targetBranchName}</strong>.
</span>
</div>
</Banner>
)
}
}

View file

@ -11,6 +11,9 @@ import { SuccessfulMerge } from './successful-merge'
import { RebaseConflictsBanner } from './rebase-conflicts-banner'
import { SuccessfulRebase } from './successful-rebase'
import { BranchAlreadyUpToDate } from './branch-already-up-to-date-banner'
import { SuccessfulCherryPick } from './successful-cherry-pick'
import { CherryPickConflictsBanner } from './cherry-pick-conflicts-banner'
import { CherryPickUndone } from './cherry-pick-undone'
export function renderBanner(
banner: Banner,
@ -63,7 +66,35 @@ export function renderBanner(
theirBranch={banner.theirBranch}
onDismissed={onDismissed}
key={'branch-already-up-to-date'}
></BranchAlreadyUpToDate>
/>
)
case BannerType.SuccessfulCherryPick:
return (
<SuccessfulCherryPick
key="successful-cherry-pick"
targetBranchName={banner.targetBranchName}
countCherryPicked={banner.countCherryPicked}
onDismissed={onDismissed}
onUndoCherryPick={banner.onUndoCherryPick}
/>
)
case BannerType.CherryPickConflictsFound:
return (
<CherryPickConflictsBanner
targetBranchName={banner.targetBranchName}
onOpenConflictsDialog={banner.onOpenConflictsDialog}
onDismissed={onDismissed}
key={'cherry-pick-conflicts'}
/>
)
case BannerType.CherryPickUndone:
return (
<CherryPickUndone
key="cherry-pick-undone"
targetBranchName={banner.targetBranchName}
countCherryPicked={banner.countCherryPicked}
onDismissed={onDismissed}
/>
)
default:
return assertNever(banner, `Unknown popup type: ${banner}`)

View file

@ -0,0 +1,45 @@
import * as React from 'react'
import { LinkButton } from '../lib/link-button'
import { Octicon, OcticonSymbol } from '../octicons'
import { Banner } from './banner'
interface ISuccessfulCherryPickBannerProps {
readonly targetBranchName: string
readonly countCherryPicked: number
readonly onDismissed: () => void
readonly onUndoCherryPick: () => void
}
export class SuccessfulCherryPick extends React.Component<
ISuccessfulCherryPickBannerProps,
{}
> {
private undo = () => {
this.props.onDismissed()
this.props.onUndoCherryPick()
}
public render() {
const { countCherryPicked, onDismissed, targetBranchName } = this.props
const pluralized = countCherryPicked === 1 ? 'commit' : 'commits'
return (
<Banner
id="successful-cherry-pick"
timeout={7500}
onDismissed={onDismissed}
>
<div className="green-circle">
<Octicon className="check-icon" symbol={OcticonSymbol.check} />
</div>
<div className="banner-message">
<span>
Successfully copied {countCherryPicked} {pluralized} to{' '}
<strong>{targetBranchName}</strong>.{' '}
<LinkButton onClick={this.undo}>Undo</LinkButton>
</span>
</div>
</Banner>
)
}
}

View file

@ -7,6 +7,8 @@ import { Octicon, OcticonSymbol } from '../octicons'
import { HighlightText } from '../lib/highlight-text'
import { showContextualMenu } from '../main-process-proxy'
import { IMenuItem } from '../../lib/menu-item'
import { String } from 'aws-sdk/clients/apigateway'
import classNames from 'classnames'
interface IBranchListItemProps {
/** The name of the branch */
@ -27,10 +29,25 @@ interface IBranchListItemProps {
readonly onRenameBranch?: (branchName: string) => void
readonly onDeleteBranch?: (branchName: string) => void
readonly onDropOntoBranch?: (branchName: String) => void
}
interface IBranchListItemState {
readonly isDraggedOver: boolean
}
/** The branch component. */
export class BranchListItem extends React.Component<IBranchListItemProps, {}> {
export class BranchListItem extends React.Component<
IBranchListItemProps,
IBranchListItemState
> {
public constructor(props: IBranchListItemProps) {
super(props)
this.state = { isDraggedOver: false }
}
private onContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
@ -65,6 +82,26 @@ export class BranchListItem extends React.Component<IBranchListItemProps, {}> {
showContextualMenu(items)
}
private onDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
this.setState({ isDraggedOver: true })
}
private onDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
this.setState({ isDraggedOver: false })
}
private onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
}
private onDrop = (e: React.DragEvent<HTMLDivElement>) => {
const { onDropOntoBranch, name } = this.props
if (onDropOntoBranch !== undefined) {
onDropOntoBranch(name)
}
}
public render() {
const lastCommitDate = this.props.lastCommitDate
const isCurrentBranch = this.props.isCurrentBranch
@ -77,8 +114,20 @@ export class BranchListItem extends React.Component<IBranchListItemProps, {}> {
: lastCommitDate
? lastCommitDate.toString()
: ''
const className = classNames('branches-list-item', {
'dragged-over': this.state.isDraggedOver,
})
return (
<div onContextMenu={this.onContextMenu} className="branches-list-item">
<div
onContextMenu={this.onContextMenu}
className={className}
onDragLeave={this.onDragLeave}
onDragEnter={this.onDragEnter}
onDragOver={this.onDragOver}
onDrop={this.onDrop}
>
<Octicon className="icon" symbol={icon} />
<div className="name" title={name}>
<HighlightText text={name} highlight={this.props.matches.title} />

View file

@ -11,7 +11,8 @@ export function renderDefaultBranch(
matches: IMatches,
currentBranch: Branch | null,
onRenameBranch?: (branchName: string) => void,
onDeleteBranch?: (branchName: string) => void
onDeleteBranch?: (branchName: string) => void,
onDropOntoBranch?: (branchName: string) => void
): JSX.Element {
const branch = item.branch
const commit = branch.tip
@ -25,6 +26,7 @@ export function renderDefaultBranch(
matches={matches}
onRenameBranch={onRenameBranch}
onDeleteBranch={onDeleteBranch}
onDropOntoBranch={onDropOntoBranch}
/>
)
}

View file

@ -151,7 +151,8 @@ export class BranchesContainer extends React.Component<
matches,
this.props.currentBranch,
this.onRenameBranch,
this.onDeleteBranch
this.onDeleteBranch,
this.onDropOntoBranch
)
}
@ -309,4 +310,30 @@ export class BranchesContainer extends React.Component<
existsOnRemote: branch.upstreamRemoteName !== null,
})
}
/**
* Method is to handle when something is dragged and dropped onto a branch
* in the branch dropdown.
*
* Currently this is being implemented with cherry picking. But, this could be
* expanded if we ever dropped something else on a branch; in which case,
* we would likely have to check the app state to see what action is being
* performed. As this branch container is not being used anywhere except
* for the branch dropdown, we are not going to pass the repository state down
* during this implementation.
*/
private onDropOntoBranch = (branchName: string) => {
const branch = this.props.allBranches.find(b => b.name === branchName)
if (branch === undefined) {
log.warn(
'[branches-container] - Branch name of branch dropped on does not exist.'
)
return
}
this.props.dispatcher.startCherryPickWithBranch(
this.props.repository,
branch
)
}
}

View file

@ -122,6 +122,8 @@ export class CommitMessage extends React.Component<
private descriptionTextArea: HTMLTextAreaElement | null = null
private descriptionTextAreaScrollDebounceId: number | null = null
private coAuthorInputRef = React.createRef<AuthorInput>()
public constructor(props: ICommitMessageProps) {
super(props)
const { commitMessage } = this.props
@ -181,6 +183,14 @@ export class CommitMessage extends React.Component<
if (this.props.focusCommitMessage) {
this.focusSummary()
} else if (
prevProps.showCoAuthoredBy === false &&
this.isCoAuthorInputVisible &&
// The co-author input could be also shown when switching between repos,
// but in that case we don't want to give the focus to the input.
prevProps.repository.id === this.props.repository.id
) {
this.coAuthorInputRef.current?.focus()
}
}
@ -345,6 +355,7 @@ export class CommitMessage extends React.Component<
return (
<AuthorInput
ref={this.coAuthorInputRef}
onAuthorsUpdated={this.onCoAuthorsUpdated}
authors={this.props.coAuthors}
autoCompleteProvider={autocompletionProvider}

View file

@ -0,0 +1,214 @@
import * as React from 'react'
import {
WorkingDirectoryStatus,
WorkingDirectoryFileChange,
} from '../../models/status'
import { Repository } from '../../models/repository'
import {
getUnmergedFiles,
getConflictedFiles,
isConflictedFile,
getResolvedFiles,
} from '../../lib/status'
import {
renderUnmergedFilesSummary,
renderShellLink,
renderAllResolved,
} from '../lib/conflicts/render-functions'
import { renderUnmergedFile } from '../lib/conflicts/unmerged-file'
import {
DialogContent,
Dialog,
DialogFooter,
OkCancelButtonGroup,
} from '../dialog'
import { Dispatcher } from '../dispatcher'
import { ShowConflictsStep } from '../../models/cherry-pick'
interface ICherryPickConflictsDialogProps {
readonly dispatcher: Dispatcher
readonly repository: Repository
readonly step: ShowConflictsStep
readonly userHasResolvedConflicts: boolean
readonly workingDirectory: WorkingDirectoryStatus
// For display in manual resolution context menu
readonly sourceBranchName: string | null
readonly onDismissed: () => void
readonly onContinueCherryPick: (step: ShowConflictsStep) => void
readonly onAbortCherryPick: (step: ShowConflictsStep) => void
readonly showCherryPickConflictsBanner: (step: ShowConflictsStep) => void
readonly openFileInExternalEditor: (path: string) => void
readonly resolvedExternalEditor: string | null
readonly openRepositoryInShell: (repository: Repository) => void
}
interface ICherryPickConflictsDialogState {
readonly isAborting: boolean
}
export class CherryPickConflictsDialog extends React.Component<
ICherryPickConflictsDialogProps,
ICherryPickConflictsDialogState
> {
public constructor(props: ICherryPickConflictsDialogProps) {
super(props)
this.state = {
isAborting: false,
}
}
public componentWillUnmount() {
const {
workingDirectory,
step,
userHasResolvedConflicts,
dispatcher,
repository,
} = this.props
// skip this work once we know conflicts have been resolved
if (userHasResolvedConflicts) {
return
}
const { conflictState } = step
const { manualResolutions } = conflictState
const resolvedConflicts = getResolvedFiles(
workingDirectory,
manualResolutions
)
if (resolvedConflicts.length > 0) {
dispatcher.setCherryPickConflictsResolved(repository)
}
}
private onCancel = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
this.setState({ isAborting: true })
this.props.onAbortCherryPick(this.props.step)
this.setState({ isAborting: false })
}
private onDismissed = () => {
this.props.onDismissed()
this.props.showCherryPickConflictsBanner(this.props.step)
}
private onSubmit = async () => {
this.props.onContinueCherryPick(this.props.step)
}
private openThisRepositoryInShell = () =>
this.props.openRepositoryInShell(this.props.repository)
private renderUnmergedFiles(
files: ReadonlyArray<WorkingDirectoryFileChange>
) {
const {
resolvedExternalEditor,
openFileInExternalEditor,
repository,
dispatcher,
step,
sourceBranchName,
} = this.props
const {
manualResolutions,
targetBranchName: theirBranch,
} = step.conflictState
return (
<ul className="unmerged-file-statuses">
{files.map(f =>
isConflictedFile(f.status)
? renderUnmergedFile({
path: f.path,
status: f.status,
resolvedExternalEditor,
openFileInExternalEditor,
repository,
dispatcher,
manualResolution: manualResolutions.get(f.path),
theirBranch,
ourBranch:
sourceBranchName !== null ? sourceBranchName : undefined,
})
: null
)}
</ul>
)
}
private renderContent(
unmergedFiles: ReadonlyArray<WorkingDirectoryFileChange>,
conflictedFilesCount: number
): JSX.Element {
if (unmergedFiles.length === 0) {
return renderAllResolved()
}
return (
<>
{renderUnmergedFilesSummary(conflictedFilesCount)}
{this.renderUnmergedFiles(unmergedFiles)}
{renderShellLink(this.openThisRepositoryInShell)}
</>
)
}
public render() {
const { workingDirectory, step } = this.props
const { manualResolutions } = step.conflictState
const unmergedFiles = getUnmergedFiles(workingDirectory)
const conflictedFilesCount = getConflictedFiles(
workingDirectory,
manualResolutions
).length
const tooltipString =
conflictedFilesCount > 0
? 'Resolve all conflicts before continuing'
: undefined
const ok = __DARWIN__ ? 'Continue Cherry Pick' : 'Continue cherry pick'
const cancel = __DARWIN__ ? 'Abort Cherry Pick' : 'Abort cherry pick'
return (
<Dialog
id="cherry-pick-conflicts-list"
onDismissed={this.onDismissed}
title="Resolve conflicts before cherry picking"
onSubmit={this.onSubmit}
>
<DialogContent>
{this.renderContent(unmergedFiles, conflictedFilesCount)}
</DialogContent>
<DialogFooter>
<OkCancelButtonGroup
okButtonText={ok}
okButtonDisabled={conflictedFilesCount > 0}
okButtonTitle={tooltipString}
cancelButtonText={cancel}
cancelButtonDisabled={this.state.isAborting}
onCancelButtonClick={this.onCancel}
/>
</DialogFooter>
</Dialog>
)
}
}

View file

@ -0,0 +1,233 @@
import * as React from 'react'
import { assertNever } from '../../lib/fatal-error'
import { Branch } from '../../models/branch'
import {
CherryPickFlowStep,
CherryPickStepKind,
ConfirmAbortStep,
ShowConflictsStep,
} from '../../models/cherry-pick'
import { ICherryPickProgress } from '../../models/progress'
import { Repository } from '../../models/repository'
import { Dispatcher } from '../dispatcher'
import { ChooseTargetBranchDialog } from './choose-target-branch'
import { CherryPickProgressDialog } from './cherry-pick-progress-dialog'
import { CommitOneLine } from '../../models/commit'
import { CherryPickConflictsDialog } from './cherry-pick-conflicts-dialog'
import { WorkingDirectoryStatus } from '../../models/status'
import { getResolvedFiles } from '../../lib/status'
import { ConfirmCherryPickAbortDialog } from './confirm-cherry-pick-abort-dialog'
interface ICherryPickFlowProps {
readonly repository: Repository
readonly dispatcher: Dispatcher
readonly step: CherryPickFlowStep
readonly commits: ReadonlyArray<CommitOneLine>
readonly progress: ICherryPickProgress | null
readonly emoji: Map<string, string>
/**
* The branch the commits come from - needed so abort can switch back to it
*
* This can be null because if a cherry pick is started outside of Desktop
* it is difficult to obtain the sourceBranch.
*/
readonly sourceBranch: Branch | null
/** Properties required for conflict flow step. */
readonly workingDirectory: WorkingDirectoryStatus
readonly userHasResolvedConflicts: boolean
/**
* Callbacks for the conflict selection components to let the user jump out
* to their preferred editor.
*/
readonly openFileInExternalEditor: (path: string) => void
readonly resolvedExternalEditor: string | null
readonly openRepositoryInShell: (repository: Repository) => void
readonly onDismissed: () => void
/**
* Callback to hide the cherry pick flow and show a banner about the current
* state of conflicts.
*/
readonly onShowCherryPickConflictsBanner: (
repository: Repository,
targetBranchName: string,
sourceBranch: Branch | null,
commits: ReadonlyArray<CommitOneLine>
) => void
}
/** A component for initiating and performing a cherry pick. */
export class CherryPickFlow extends React.Component<ICherryPickFlowProps> {
private onFlowEnded = () => {
this.props.onDismissed()
}
private onCherryPick = (targetBranch: Branch) => {
const { dispatcher, repository, commits, sourceBranch } = this.props
dispatcher.cherryPick(repository, targetBranch, commits, sourceBranch)
}
private onContinueCherryPick = (step: ShowConflictsStep) => {
const {
dispatcher,
repository,
workingDirectory,
commits,
sourceBranch,
} = this.props
dispatcher.continueCherryPick(
repository,
workingDirectory.files,
step.conflictState,
commits.length,
sourceBranch
)
}
private onAbortCherryPick = (step: ShowConflictsStep) => {
const {
dispatcher,
repository,
workingDirectory,
userHasResolvedConflicts,
} = this.props
const { conflictState } = step
const { manualResolutions } = conflictState
const { length: countResolvedConflicts } = getResolvedFiles(
workingDirectory,
manualResolutions
)
if (userHasResolvedConflicts || countResolvedConflicts > 0) {
dispatcher.setCherryPickFlowStep(repository, {
kind: CherryPickStepKind.ConfirmAbort,
conflictState,
})
return
}
this.abortCherryPick()
}
private abortCherryPick = async () => {
const { dispatcher, repository, sourceBranch } = this.props
await dispatcher.abortCherryPick(repository, sourceBranch)
dispatcher.closePopup()
}
private showCherryPickConflictsBanner = (step: ShowConflictsStep) => {
const { repository, sourceBranch, commits } = this.props
this.props.onShowCherryPickConflictsBanner(
repository,
step.conflictState.targetBranchName,
sourceBranch,
commits
)
}
private moveToShowConflictedFileState = (step: ConfirmAbortStep) => {
const { conflictState } = step
const { dispatcher, repository } = this.props
dispatcher.setCherryPickFlowStep(repository, {
kind: CherryPickStepKind.ShowConflicts,
conflictState,
})
}
public render() {
const { step } = this.props
switch (step.kind) {
case CherryPickStepKind.ChooseTargetBranch: {
const {
allBranches,
defaultBranch,
currentBranch,
recentBranches,
} = step
return (
<ChooseTargetBranchDialog
key="choose-target-branch"
allBranches={allBranches}
defaultBranch={defaultBranch}
recentBranches={recentBranches}
currentBranch={currentBranch}
onCherryPick={this.onCherryPick}
onDismissed={this.onFlowEnded}
/>
)
}
case CherryPickStepKind.ShowProgress:
if (this.props.progress === null) {
log.error(
`[CherryPickFlow] cherry pick progress should not be null
when showing progress. Skipping rendering..`
)
return null
}
return (
<CherryPickProgressDialog
progress={this.props.progress}
emoji={this.props.emoji}
/>
)
case CherryPickStepKind.ShowConflicts:
const {
repository,
resolvedExternalEditor,
openFileInExternalEditor,
openRepositoryInShell,
dispatcher,
workingDirectory,
userHasResolvedConflicts,
sourceBranch,
} = this.props
return (
<CherryPickConflictsDialog
dispatcher={dispatcher}
repository={repository}
step={step}
userHasResolvedConflicts={userHasResolvedConflicts}
workingDirectory={workingDirectory}
onDismissed={this.onFlowEnded}
onContinueCherryPick={this.onContinueCherryPick}
onAbortCherryPick={this.onAbortCherryPick}
showCherryPickConflictsBanner={this.showCherryPickConflictsBanner}
openFileInExternalEditor={openFileInExternalEditor}
resolvedExternalEditor={resolvedExternalEditor}
openRepositoryInShell={openRepositoryInShell}
sourceBranchName={sourceBranch !== null ? sourceBranch.name : null}
/>
)
case CherryPickStepKind.ConfirmAbort:
const {
commits: { length: commitCount },
} = this.props
const sourceBranchName =
this.props.sourceBranch !== null ? this.props.sourceBranch.name : null
return (
<ConfirmCherryPickAbortDialog
step={step}
commitCount={commitCount}
sourceBranchName={sourceBranchName}
onReturnToConflicts={this.moveToShowConflictedFileState}
onConfirmAbort={this.abortCherryPick}
/>
)
case CherryPickStepKind.CommitsChosen:
case CherryPickStepKind.HideConflicts:
// no ui for this part of flow
return null
default:
return assertNever(step, 'Unknown cherry pick step found')
}
}
}

View file

@ -0,0 +1,60 @@
import * as React from 'react'
import { RichText } from '../lib/rich-text'
import { Dialog, DialogContent } from '../dialog'
import { Octicon, OcticonSymbol } from '../octicons'
import { ICherryPickProgress } from '../../models/progress'
interface ICherryPickProgressDialogProps {
/** Progress information about the current cherry pick */
readonly progress: ICherryPickProgress
readonly emoji: Map<string, string>
}
export class CherryPickProgressDialog extends React.Component<
ICherryPickProgressDialogProps
> {
// even tho dialog is not dismissable, it requires an onDismissed method
private onDismissed = () => {}
public render() {
const {
cherryPickCommitCount,
totalCommitCount,
value,
currentCommitSummary,
} = this.props.progress
return (
<Dialog
dismissable={false}
onDismissed={this.onDismissed}
id="cherry-pick-progress"
title="Cherry pick in progress"
>
<DialogContent>
<div>
<progress value={value} />
<div className="details">
<div className="green-circle">
<Octicon symbol={OcticonSymbol.check} />
</div>
<div className="summary">
<div className="message">
Commit {cherryPickCommitCount} of {totalCommitCount}
</div>
<div className="detail">
<RichText
emoji={this.props.emoji}
text={currentCommitSummary || ''}
/>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
}

View file

@ -0,0 +1,173 @@
import * as React from 'react'
import { Branch } from '../../models/branch'
import { IMatches } from '../../lib/fuzzy-find'
import {
Dialog,
DialogContent,
DialogFooter,
OkCancelButtonGroup,
} from '../dialog'
import { BranchList, IBranchListItem, renderDefaultBranch } from '../branches'
import { ClickSource } from '../lib/list'
interface IChooseTargetBranchDialogProps {
/**
* See IBranchesState.defaultBranch
*/
readonly defaultBranch: Branch | null
/**
* The currently checked out branch
*/
readonly currentBranch: Branch
/**
* See IBranchesState.allBranches
*/
readonly allBranches: ReadonlyArray<Branch>
/**
* See IBranchesState.recentBranches
*/
readonly recentBranches: ReadonlyArray<Branch>
/**
* A function that's called when the user selects a branch and hits start
* cherry pick
*/
readonly onCherryPick: (targetBranch: Branch) => void
/**
* A function that's called when the dialog is dismissed by the user in the
* ways described in the Dialog component's dismissable prop.
*/
readonly onDismissed: () => void
}
interface IChooseTargetBranchDialogState {
/** The currently selected branch. */
readonly selectedBranch: Branch | null
/** The filter text to use in the branch selector */
readonly filterText: string
}
/** A component for initiating a rebase of the current branch. */
export class ChooseTargetBranchDialog extends React.Component<
IChooseTargetBranchDialogProps,
IChooseTargetBranchDialogState
> {
public constructor(props: IChooseTargetBranchDialogProps) {
super(props)
this.state = {
selectedBranch: null,
filterText: '',
}
}
private onFilterTextChanged = (filterText: string) => {
this.setState({ filterText })
}
private onSelectionChanged = (selectedBranch: Branch | null) => {
this.setState({ selectedBranch })
}
private renderBranch = (item: IBranchListItem, matches: IMatches) => {
return renderDefaultBranch(item, matches, this.props.currentBranch)
}
private onEnterPressed = (branch: Branch, source: ClickSource) => {
if (source.kind !== 'keyboard' || source.event.key !== 'Enter') {
return
}
source.event.preventDefault()
const { selectedBranch } = this.state
if (selectedBranch !== null && selectedBranch.name === branch.name) {
this.startCherryPick()
}
}
private canCherryPickOntoSelectedBranch() {
const { selectedBranch } = this.state
return selectedBranch !== null && !this.selectedBranchIsCurrentBranch()
}
private selectedBranchIsCurrentBranch() {
const { selectedBranch } = this.state
const currentBranch = this.props.currentBranch
return (
selectedBranch !== null &&
currentBranch !== null &&
selectedBranch.name === currentBranch.name
)
}
private renderOkButtonText() {
const okButtonText = 'Cherry pick commit'
const { selectedBranch } = this.state
if (selectedBranch !== null) {
return (
<>
{okButtonText} to <strong>{selectedBranch.name}</strong>
</>
)
}
return okButtonText
}
public render() {
const tooltip = this.selectedBranchIsCurrentBranch()
? 'You are not able to cherry pick from and to the same branch'
: undefined
return (
<Dialog
id="cherry-pick"
onDismissed={this.props.onDismissed}
onSubmit={this.startCherryPick}
dismissable={true}
title={<strong>Cherry pick commit to a branch</strong>}
>
<DialogContent>
<BranchList
allBranches={this.props.allBranches}
currentBranch={this.props.currentBranch}
defaultBranch={this.props.defaultBranch}
recentBranches={this.props.recentBranches}
filterText={this.state.filterText}
onFilterTextChanged={this.onFilterTextChanged}
selectedBranch={this.state.selectedBranch}
onSelectionChanged={this.onSelectionChanged}
canCreateNewBranch={false}
renderBranch={this.renderBranch}
onItemClick={this.onEnterPressed}
/>
</DialogContent>
<DialogFooter>
<OkCancelButtonGroup
okButtonText={this.renderOkButtonText()}
okButtonDisabled={!this.canCherryPickOntoSelectedBranch()}
okButtonTitle={tooltip}
cancelButtonVisible={false}
/>
</DialogFooter>
</Dialog>
)
}
private startCherryPick = async () => {
const { selectedBranch } = this.state
if (selectedBranch === null || !this.canCherryPickOntoSelectedBranch()) {
return
}
this.props.onCherryPick(selectedBranch)
}
}

View file

@ -0,0 +1,107 @@
import * as React from 'react'
import { Dialog, DialogContent, DialogFooter } from '../dialog'
import { Ref } from '../lib/ref'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { ConfirmAbortStep } from '../../models/cherry-pick'
interface IConfirmCherryPickAbortDialogProps {
readonly step: ConfirmAbortStep
readonly commitCount: number
readonly sourceBranchName: string | null
readonly onReturnToConflicts: (step: ConfirmAbortStep) => void
readonly onConfirmAbort: () => Promise<void>
}
interface IConfirmCherryPickAbortDialogState {
readonly isAborting: boolean
}
export class ConfirmCherryPickAbortDialog extends React.Component<
IConfirmCherryPickAbortDialogProps,
IConfirmCherryPickAbortDialogState
> {
public constructor(props: IConfirmCherryPickAbortDialogProps) {
super(props)
this.state = {
isAborting: false,
}
}
private onSubmit = async () => {
this.setState({
isAborting: true,
})
await this.props.onConfirmAbort()
this.setState({
isAborting: false,
})
}
private onCancel = async () => {
await this.props.onReturnToConflicts(this.props.step)
}
private renderTextContent() {
const { commitCount, step, sourceBranchName } = this.props
const { targetBranchName } = step.conflictState
const pluralize = commitCount > 1 ? 'commits' : 'commit'
const confirm = (
<p>
{`Are you sure you want to abort cherry picking ${commitCount} ${pluralize}`}
{' onto '}
<Ref>{targetBranchName}</Ref>?
</p>
)
let returnTo = null
if (sourceBranchName !== null) {
returnTo = (
<>
{' and you will be taken back to '}
<Ref>{sourceBranchName}</Ref>
</>
)
}
return (
<div className="column-left">
{confirm}
<p>
{'The conflicts you have already resolved will be discarded'}
{returnTo}
{'.'}
</p>
</div>
)
}
public render() {
return (
<Dialog
id="abort-merge-warning"
title={
__DARWIN__ ? 'Confirm Abort Cherry Pick' : 'Confirm abort cherry pick'
}
onDismissed={this.onCancel}
onSubmit={this.onSubmit}
disabled={this.state.isAborting}
type="warning"
>
<DialogContent>{this.renderTextContent()}</DialogContent>
<DialogFooter>
<OkCancelButtonGroup
destructive={true}
okButtonText={
__DARWIN__ ? 'Abort Cherry Pick' : 'Abort cherry pick'
}
/>
</DialogFooter>
</Dialog>
)
}
}

View file

@ -13,8 +13,10 @@ import {
FoldoutType,
ICompareFormUpdate,
RepositorySectionTab,
isMergeConflictState,
RebaseConflictState,
isRebaseConflictState,
isCherryPickConflictState,
CherryPickConflictState,
} from '../../lib/app-state'
import { assertNever, fatalError } from '../../lib/fatal-error'
import {
@ -97,6 +99,12 @@ import { IStashEntry } from '../../models/stash-entry'
import { WorkflowPreferences } from '../../models/workflow-preferences'
import { enableForkSettings } from '../../lib/feature-flag'
import { resolveWithin } from '../../lib/path'
import {
CherryPickFlowStep,
CherryPickStepKind,
} from '../../models/cherry-pick'
import { CherryPickResult } from '../../lib/git/cherry-pick'
import { sleep } from '../../lib/promise'
/**
* An error handler function.
@ -212,9 +220,9 @@ export class Dispatcher {
*/
public changeCommitSelection(
repository: Repository,
sha: string
shas: ReadonlyArray<string>
): Promise<void> {
return this.appStore._changeCommitSelection(repository, sha)
return this.appStore._changeCommitSelection(repository, shas)
}
/**
@ -450,7 +458,7 @@ export class Dispatcher {
const repositoryState = this.repositoryStateManager.get(repository)
const { conflictState } = repositoryState.changesState
if (conflictState === null || conflictState.kind === 'merge') {
if (conflictState === null || !isRebaseConflictState(conflictState)) {
return
}
@ -1020,9 +1028,9 @@ export class Dispatcher {
return
}
if (isMergeConflictState(conflictState)) {
if (!isRebaseConflictState(conflictState)) {
log.warn(
`[rebase] conflict state after rebase is merge conflicts - unable to continue`
`[rebase] conflict state after rebase is not rebase conflicts - unable to continue`
)
return
}
@ -1113,9 +1121,9 @@ export class Dispatcher {
return
}
if (isMergeConflictState(conflictState)) {
if (!isRebaseConflictState(conflictState)) {
log.warn(
`[continueRebase] conflict state after rebase is merge conflicts - unable to continue`
`[continueRebase] conflict state after rebase is not rebase conflicts - unable to continue`
)
return
}
@ -1903,6 +1911,13 @@ export class Dispatcher {
retryAction.baseBranch,
retryAction.targetBranch
)
case RetryActionType.CherryPick:
return this.cherryPick(
retryAction.repository,
retryAction.targetBranch,
retryAction.commits,
retryAction.sourceBranch
)
default:
return assertNever(retryAction, `Unknown retry action: ${retryAction}`)
@ -2505,16 +2520,299 @@ export class Dispatcher {
}
/**
* Show the cherry pick branch selection dialog
* Move the cherry pick flow to a new state.
*/
public showCherryPickBranchDialog(
public setCherryPickFlowStep(
repository: Repository,
commitSha: string
step: CherryPickFlowStep
): Promise<void> {
return this.showPopup({
type: PopupType.CherryPick,
return this.appStore._setCherryPickFlowStep(repository, step)
}
/** Initialize and start the cherry pick operation */
public async initializeCherryPickFlow(
repository: Repository,
commits: ReadonlyArray<CommitOneLine>
): Promise<void> {
this.appStore._initializeCherryPickProgress(repository, commits)
this.switchCherryPickingFlowToShowProgress(repository)
}
private logHowToRevertCherryPick(
repository: Repository,
targetBranch: Branch
) {
const beforeSha = targetBranch.tip.sha
this.appStore._setCherryPickTargetBranchUndoSha(repository, beforeSha)
log.info(
`[cherryPick] starting cherry pick for ${targetBranch.name} at ${beforeSha}`
)
log.info(
`[cherryPick] to restore the previous state if this completed cherry pick is unsatisfactory:`
)
log.info(`[cherryPick] - git checkout ${targetBranch.name}`)
log.info(`[cherryPick] - git reset ${beforeSha} --hard`)
}
/** Starts a cherry pick of the given commits onto the target branch */
public async cherryPick(
repository: Repository,
targetBranch: Branch,
commits: ReadonlyArray<CommitOneLine>,
sourceBranch: Branch | null
): Promise<void> {
this.initializeCherryPickFlow(repository, commits)
this.dismissCherryPickIntro()
this.logHowToRevertCherryPick(repository, targetBranch)
const result = await this.appStore._cherryPick(
repository,
commitSha,
targetBranch,
commits,
sourceBranch
)
this.processCherryPickResult(
repository,
result,
targetBranch.name,
commits.length,
sourceBranch
)
}
/**
* Obtains the current app conflict state and switches cherry pick flow to
* show conflicts step
*/
private startConflictCherryPickFlow(repository: Repository): void {
const stateAfter = this.repositoryStateManager.get(repository)
const { conflictState } = stateAfter.changesState
if (conflictState === null || !isCherryPickConflictState(conflictState)) {
log.warn(
'[cherryPick] - conflict state was null or not in a cherry pick conflict state - unable to continue'
)
return
}
this.setCherryPickFlowStep(repository, {
kind: CherryPickStepKind.ShowConflicts,
conflictState,
})
}
/** Tidy up the cherry pick flow after reaching the end */
/** Wrap cherry pick up actions:
* - closes flow popup
* - displays success banner
* - clears out cherry pick flow state
*/
private async completeCherryPick(
repository: Repository,
targetBranchName: string,
countCherryPicked: number,
sourceBranch: Branch | null,
commitsCount: number
): Promise<void> {
this.closePopup()
const banner: Banner = {
type: BannerType.SuccessfulCherryPick,
targetBranchName,
countCherryPicked,
onUndoCherryPick: () => {
this.undoCherryPick(
repository,
targetBranchName,
sourceBranch,
commitsCount
)
},
}
this.setBanner(banner)
this.appStore._endCherryPickFlow(repository)
await this.refreshRepository(repository)
}
/** Aborts an ongoing cherry pick and switches back to the source branch. */
public async abortCherryPick(
repository: Repository,
sourceBranch: Branch | null
) {
await this.appStore._abortCherryPick(repository, sourceBranch)
await this.appStore._loadStatus(repository)
this.appStore._endCherryPickFlow(repository)
}
/**
* Update the cherry pick state to indicate the user has resolved conflicts in
* the current repository.
*/
public setCherryPickConflictsResolved(repository: Repository) {
return this.appStore._setCherryPickConflictsResolved(repository)
}
/**
* Moves cherry pick flow step to progress and defers to allow user to
* see the cherry picking progress dialog instead of suddenly appearing
* and disappearing again.
*/
private async switchCherryPickingFlowToShowProgress(repository: Repository) {
this.setCherryPickFlowStep(repository, {
kind: CherryPickStepKind.ShowProgress,
})
await sleep(500)
}
/**
* Continue with the cherryPick after the user has resolved all conflicts with
* tracked files in the working directory.
*/
public async continueCherryPick(
repository: Repository,
files: ReadonlyArray<WorkingDirectoryFileChange>,
conflictsState: CherryPickConflictState,
commitsCount: number,
sourceBranch: Branch | null
): Promise<void> {
await this.switchCherryPickingFlowToShowProgress(repository)
const result = await this.appStore._continueCherryPick(
repository,
files,
conflictsState.manualResolutions
)
this.processCherryPickResult(
repository,
result,
conflictsState.targetBranchName,
commitsCount,
sourceBranch
)
}
/**
* Processes the cherry pick result.
* 1. Completes the cherry pick with banner if successful.
* 2. Moves cherry pick flow if conflicts.
* 3. Handles errors.
*/
private async processCherryPickResult(
repository: Repository,
cherryPickResult: CherryPickResult,
targetBranchName: string,
commitsCount: number,
sourceBranch: Branch | null
): Promise<void> {
// This will update the conflict state of the app. This is needed to start
// conflict flow if cherry pick results in conflict.
await this.appStore._loadStatus(repository)
switch (cherryPickResult) {
case CherryPickResult.CompletedWithoutError:
await this.completeCherryPick(
repository,
targetBranchName,
commitsCount,
sourceBranch,
commitsCount
)
break
case CherryPickResult.ConflictsEncountered:
this.startConflictCherryPickFlow(repository)
break
case CherryPickResult.UnableToStart:
// This is an expected error such as not being able to checkout the
// target branch which means the cherry pick operation never started or
// was cleanly aborted.
this.appStore._endCherryPickFlow(repository)
break
default:
// If the user closes error dialog and tries to cherry pick again, it
// will fail again due to ongoing cherry pick. Thus, if we get to an
// unhandled error state, we want to abort any ongoing cherry pick.
// A known error is if a user attempts to cherry pick a merge commit.
this.appStore._clearCherryPickingHead(repository, sourceBranch)
this.appStore._endCherryPickFlow(repository)
this.appStore._closePopup()
}
}
/**
* Update the cherry pick progress in application state by querying the Git
* repository state.
*/
public setCherryPickProgressFromState(repository: Repository) {
return this.appStore._setCherryPickProgressFromState(repository)
}
/**
* This method starts a cherry pick after drag and dropping on a branch.
* It needs to:
* - get the current branch,
* - get the commits dragged from cherry picking state
* - invoke popup
* - invoke cherry pick
*/
public async startCherryPickWithBranch(
repository: Repository,
targetBranch: Branch
): Promise<void> {
const { branchesState, cherryPickState } = this.repositoryStateManager.get(
repository
)
if (
cherryPickState.step == null ||
cherryPickState.step.kind !== CherryPickStepKind.CommitsChosen
) {
log.warn(
'[cherryPick] Invalid Cherry Picking State: Could not determine selected commits.'
)
return
}
const { tip } = branchesState
if (tip.kind !== TipState.Valid) {
throw new Error(
'Tip is not in a valid state, which is required to start the cherry pick flow.'
)
}
const sourceBranch = tip.branch
const commits = cherryPickState.step.commits
this.showPopup({
type: PopupType.CherryPick,
repository,
commits,
sourceBranch,
})
this.cherryPick(repository, targetBranch, commits, sourceBranch)
}
/** Method to dismiss cherry pick intro */
public dismissCherryPickIntro(): void {
this.appStore._dismissCherryPickIntro()
}
/**
* This method will perform a hard reset back to the tip of the target branch
* before the cherry pick happened.
*/
private async undoCherryPick(
repository: Repository,
targetBranchName: string,
sourceBranch: Branch | null,
commitsCount: number
): Promise<void> {
await this.appStore._undoCherryPick(
repository,
targetBranchName,
sourceBranch,
commitsCount
)
}
}

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import { Commit } from '../../models/commit'
import { Commit, CommitOneLine } from '../../models/commit'
import { GitHubRepository } from '../../models/github-repository'
import { IAvatarUser, getAvatarUsersForCommit } from '../../models/avatar'
import { RichText } from '../lib/rich-text'
@ -20,13 +20,16 @@ import {
interface ICommitProps {
readonly gitHubRepository: GitHubRepository | null
readonly commit: Commit
readonly selectedCommits: ReadonlyArray<Commit>
readonly emoji: Map<string, string>
readonly isLocal: boolean
readonly onRevertCommit?: (commit: Commit) => void
readonly onViewCommitOnGitHub?: (sha: string) => void
readonly onCreateTag?: (targetCommitSha: string) => void
readonly onDeleteTag?: (tagName: string) => void
readonly onCherryPick?: (commitSha: string) => void
readonly onCherryPick?: (commits: ReadonlyArray<CommitOneLine>) => void
readonly onDragStart?: (commits: ReadonlyArray<CommitOneLine>) => void
readonly onDragEnd?: () => void
readonly showUnpushedIndicator: boolean
readonly unpushedIndicatorTitle?: string
readonly unpushedTags?: ReadonlyArray<string>
@ -69,8 +72,17 @@ export class CommitListItem extends React.PureComponent<
author: { date },
} = commit
const isDraggable =
this.props.onDragStart !== undefined && enableCherryPicking()
return (
<div className="commit" onContextMenu={this.onContextMenu}>
<div
className="commit"
onContextMenu={this.onContextMenu}
draggable={isDraggable}
onDragStart={this.onDragStart}
onDragEnd={this.onDragEnd}
>
<div className="info">
<RichText
className="summary"
@ -146,13 +158,24 @@ export class CommitListItem extends React.PureComponent<
private onCherryPick = () => {
if (this.props.onCherryPick !== undefined) {
this.props.onCherryPick(this.props.commit.sha)
this.props.onCherryPick(this.props.selectedCommits)
}
}
private onContextMenu = (event: React.MouseEvent<any>) => {
event.preventDefault()
let items: IMenuItem[] = []
if (this.props.selectedCommits.length > 1) {
items = this.getContextMenuMultipleCommits()
} else {
items = this.getContextMenuForSingleCommit()
}
showContextualMenu(items)
}
private getContextMenuForSingleCommit(): IMenuItem[] {
let viewOnGitHubLabel = 'View on GitHub'
const gitHubRepository = this.props.gitHubRepository
@ -216,7 +239,23 @@ export class CommitListItem extends React.PureComponent<
}
)
showContextualMenu(items)
return items
}
private getContextMenuMultipleCommits(): IMenuItem[] {
const items: IMenuItem[] = []
const count = this.props.selectedCommits.length
if (enableCherryPicking()) {
items.push({
label: __DARWIN__
? `Cherry Pick ${count} Commits…`
: `Cherry pick ${count} commits…`,
action: this.onCherryPick,
})
}
return items
}
private getDeleteTagsMenuItem(): IMenuItem | null {
@ -254,6 +293,24 @@ export class CommitListItem extends React.PureComponent<
}),
}
}
/**
* Note: For typing, event is required parameter.
**/
private onDragStart = (event: React.DragEvent<HTMLDivElement>): void => {
if (this.props.onDragStart !== undefined) {
this.props.onDragStart(this.props.selectedCommits)
}
}
/**
* Note: For typing, event is required parameter.
**/
private onDragEnd = (event: React.DragEvent<HTMLDivElement>): void => {
if (this.props.onDragEnd !== undefined) {
this.props.onDragEnd()
}
}
}
function renderRelativeTime(date: Date) {

View file

@ -1,10 +1,14 @@
import * as React from 'react'
import memoize from 'memoize-one'
import { GitHubRepository } from '../../models/github-repository'
import { Commit } from '../../models/commit'
import { Commit, CommitOneLine } from '../../models/commit'
import { CommitListItem } from './commit-list-item'
import { List } from '../lib/list'
import { List, SelectionSource } from '../lib/list'
import { arrayEquals } from '../../lib/equality'
import { Popover, PopoverCaretPosition } from '../lib/popover'
import { Button } from '../lib/button'
import { enableCherryPicking } from '../../lib/feature-flag'
import { encodePathAsUrl } from '../../lib/path'
const RowHeight = 50
@ -18,8 +22,8 @@ interface ICommitListProps {
/** The commits loaded, keyed by their full SHA. */
readonly commitLookup: Map<string, Commit>
/** The SHA of the selected commit */
readonly selectedSHA: string | null
/** The SHAs of the selected commits */
readonly selectedSHAs: ReadonlyArray<string>
/** The emoji lookup to render images inline */
readonly emoji: Map<string, string>
@ -31,7 +35,7 @@ interface ICommitListProps {
readonly emptyListMessage: JSX.Element | string
/** Callback which fires when a commit has been selected in the list */
readonly onCommitSelected: (commit: Commit) => void
readonly onCommitsSelected: (commits: ReadonlyArray<Commit>) => void
/** Callback that fires when a scroll event has occurred */
readonly onScroll: (start: number, end: number) => void
@ -49,8 +53,13 @@ interface ICommitListProps {
readonly onDeleteTag: (tagName: string) => void
/** Callback to fire to cherry picking the commit */
readonly onCherryPick: (commitSha: string) => void
readonly onCherryPick: (commits: ReadonlyArray<CommitOneLine>) => void
/** Callback to fire to when has started being dragged */
readonly onDragCommitStart: (commits: ReadonlyArray<CommitOneLine>) => void
/** Callback to fire to when has started being dragged */
readonly onDragCommitEnd: () => void
/**
* Optional callback that fires on page scroll in order to allow passing
* a new scrollTop value up to the parent component for storing.
@ -65,12 +74,17 @@ interface ICommitListProps {
/* Tags that haven't been pushed yet. This is used to show the unpushed indicator */
readonly tagsToPush: ReadonlyArray<string> | null
/* Whether or not the user has been introduced to cherry picking feature */
readonly hasShownCherryPickIntro: boolean
/** Callback to fire when cherry pick intro popover has been dismissed */
readonly onDismissCherryPickIntro: () => void
}
/** A component which displays the list of commits. */
export class CommitList extends React.Component<ICommitListProps, {}> {
private commitsHash = memoize(makeCommitsHash, arrayEquals)
private getVisibleCommits(): ReadonlyArray<Commit> {
const commits = new Array<Commit>()
for (const sha of this.props.commitSHAs) {
@ -125,6 +139,9 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
onCherryPick={this.props.onCherryPick}
onRevertCommit={this.props.onRevertCommit}
onViewCommitOnGitHub={this.props.onViewCommitOnGitHub}
selectedCommits={this.lookupCommits(this.props.selectedSHAs)}
onDragStart={this.props.onDragCommitStart}
onDragEnd={this.props.onDragCommitEnd}
/>
)
}
@ -146,14 +163,48 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
return undefined
}
private onSelectedRangeChanged = (
start: number,
end: number,
source: SelectionSource
) => {
// if user selects a range top down, start < end.
// if user selects a range down to up, start > end and need to be inverted.
// .slice is exclusive of last range end, thus + 1
const rangeStart = start < end ? start : end
const rangeEnd = start < end ? end + 1 : start + 1
const commitSHARange = this.props.commitSHAs.slice(rangeStart, rangeEnd)
const selectedCommits = this.lookupCommits(commitSHARange)
this.props.onCommitsSelected(selectedCommits)
}
// This is required along with onSelectedRangeChanged in the case of a user
// paging up/down or using arrow keys up/down.
private onSelectedRowChanged = (row: number) => {
const sha = this.props.commitSHAs[row]
const commit = this.props.commitLookup.get(sha)
if (commit) {
this.props.onCommitSelected(commit)
this.props.onCommitsSelected([commit])
}
}
private lookupCommits(
commitSHAs: ReadonlyArray<string>
): ReadonlyArray<Commit> {
const commits: Commit[] = []
commitSHAs.forEach(sha => {
const commit = this.props.commitLookup.get(sha)
if (commit === undefined) {
log.warn(
'[Commit List] - Unable to lookup commit from sha - This should not happen.'
)
return
}
commits.push(commit)
})
return commits
}
private onScroll = (scrollTop: number, clientHeight: number) => {
const numberOfRows = Math.ceil(clientHeight / RowHeight)
const top = Math.floor(scrollTop / RowHeight)
@ -175,6 +226,36 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
return this.props.commitSHAs.findIndex(s => s === sha)
}
private renderCherryPickIntroPopover() {
if (this.props.hasShownCherryPickIntro || !enableCherryPicking()) {
return null
}
const cherryPickIntro = encodePathAsUrl(
__dirname,
'static/cherry-pick-intro.png'
)
return (
<Popover caretPosition={PopoverCaretPosition.LeftTop}>
<img src={cherryPickIntro} className="cherry-pick-intro" />
<h3>
Drag and drop to cherry pick!
<span className="call-to-action-bubble">New</span>
</h3>
<p>
Copy commits to another branch by dragging and dropping them onto a
branch in the branch menu, or by right clicking on a commit.
</p>
<div>
<Button onClick={this.props.onDismissCherryPickIntro} type="submit">
Got it
</Button>
</div>
</Popover>
)
}
public render() {
if (this.props.commitSHAs.length === 0) {
return (
@ -187,9 +268,11 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
<List
rowCount={this.props.commitSHAs.length}
rowHeight={RowHeight}
selectedRows={[this.rowForSHA(this.props.selectedSHA)]}
selectedRows={this.props.selectedSHAs.map(sha => this.rowForSHA(sha))}
rowRenderer={this.renderCommit}
onSelectedRangeChanged={this.onSelectedRangeChanged}
onSelectedRowChanged={this.onSelectedRowChanged}
selectionMode={enableCherryPicking() ? 'range' : 'single'}
onScroll={this.onScroll}
invalidationProps={{
commits: this.props.commitSHAs,
@ -199,6 +282,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
}}
setScrollTop={this.props.compareListScrollTop}
/>
{this.renderCherryPickIntroPopover()}
</div>
)
}

View file

@ -1,12 +1,13 @@
import * as React from 'react'
import { Commit } from '../../models/commit'
import { Commit, CommitOneLine } from '../../models/commit'
import {
HistoryTabMode,
ICompareState,
ICompareBranch,
ComparisonMode,
IDisplayHistory,
FoldoutType,
} from '../../lib/app-state'
import { CommitList } from './commit-list'
import { Repository } from '../../models/repository'
@ -25,6 +26,7 @@ import { IMatches } from '../../lib/fuzzy-find'
import { Ref } from '../lib/ref'
import { MergeCallToActionWithConflicts } from './merge-call-to-action-with-conflicts'
import { AheadBehindStore } from '../../lib/stores/ahead-behind-store'
import { CherryPickStepKind } from '../../models/cherry-pick'
interface ICompareSidebarProps {
readonly repository: Repository
@ -35,14 +37,19 @@ interface ICompareSidebarProps {
readonly localCommitSHAs: ReadonlyArray<string>
readonly dispatcher: Dispatcher
readonly currentBranch: Branch | null
readonly selectedCommitSha: string | null
readonly selectedCommitShas: ReadonlyArray<string>
readonly onRevertCommit: (commit: Commit) => void
readonly onViewCommitOnGitHub: (sha: string) => void
readonly onCompareListScrolled: (scrollTop: number) => void
readonly onCherryPick: (
repository: Repository,
commits: ReadonlyArray<CommitOneLine>
) => void
readonly compareListScrollTop?: number
readonly localTags: Map<string, string> | null
readonly tagsToPush: ReadonlyArray<string> | null
readonly aheadBehindStore: AheadBehindStore
readonly hasShownCherryPickIntro: boolean
}
interface ICompareSidebarState {
@ -217,7 +224,7 @@ export class CompareSidebar extends React.Component<
isLocalRepository={this.props.isLocalRepository}
commitLookup={this.props.commitLookup}
commitSHAs={commitSHAs}
selectedSHA={this.props.selectedCommitSha}
selectedSHAs={this.props.selectedCommitShas}
localCommitSHAs={this.props.localCommitSHAs}
emoji={this.props.emoji}
onViewCommitOnGitHub={this.props.onViewCommitOnGitHub}
@ -226,7 +233,7 @@ export class CompareSidebar extends React.Component<
? this.props.onRevertCommit
: undefined
}
onCommitSelected={this.onCommitSelected}
onCommitsSelected={this.onCommitsSelected}
onScroll={this.onScroll}
onCreateTag={this.onCreateTag}
onDeleteTag={this.onDeleteTag}
@ -235,10 +242,18 @@ export class CompareSidebar extends React.Component<
onCompareListScrolled={this.props.onCompareListScrolled}
compareListScrollTop={this.props.compareListScrollTop}
tagsToPush={this.props.tagsToPush}
onDragCommitStart={this.onDragCommitStart}
onDragCommitEnd={this.onDragCommitEnd}
hasShownCherryPickIntro={this.props.hasShownCherryPickIntro}
onDismissCherryPickIntro={this.onDismissCherryPickIntro}
/>
)
}
private onDismissCherryPickIntro = () => {
this.props.dispatcher.dismissCherryPickIntro()
}
private renderActiveTab(view: ICompareBranch) {
return (
<div className="compare-commit-list">
@ -392,10 +407,10 @@ export class CompareSidebar extends React.Component<
}
}
private onCommitSelected = (commit: Commit) => {
private onCommitsSelected = (commits: ReadonlyArray<Commit>) => {
this.props.dispatcher.changeCommitSelection(
this.props.repository,
commit.sha
commits.map(c => c.sha)
)
this.loadChangedFilesScheduler.queue(() => {
@ -506,11 +521,31 @@ export class CompareSidebar extends React.Component<
this.props.dispatcher.showDeleteTagDialog(this.props.repository, tagName)
}
private onCherryPick = (commitSha: string) => {
this.props.dispatcher.showCherryPickBranchDialog(
this.props.repository,
commitSha
)
private onCherryPick = (commits: ReadonlyArray<CommitOneLine>) => {
this.props.onCherryPick(this.props.repository, commits)
}
/**
* This method is a generic event handler for when a commit has started being
* dragged.
*
* Currently only used for cherry picking, but this could be more generic.
*/
private onDragCommitStart = (commits: ReadonlyArray<CommitOneLine>) => {
this.props.dispatcher.setCherryPickFlowStep(this.props.repository, {
kind: CherryPickStepKind.CommitsChosen,
commits,
})
}
/**
* This method is a generic event handler for when a commit has ended being
* dragged.
*
* Currently only used for cherry picking, but this could be more generic.
*/
private onDragCommitEnd = () => {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
}
}

View file

@ -67,6 +67,9 @@ interface ISelectedCommitProps {
/** Called when the user opens the diff options popover */
readonly onDiffOptionsOpened: () => void
/** Whether multiple commits are selected. */
readonly areMultipleCommitsSelected: boolean
}
interface ISelectedCommitState {
@ -236,6 +239,10 @@ export class SelectedCommit extends React.Component<
public render() {
const commit = this.props.selectedCommit
if (this.props.areMultipleCommitsSelected) {
return <MultipleCommitsSelected />
}
if (commit == null) {
return <NoCommitSelected />
}
@ -324,3 +331,25 @@ function NoCommitSelected() {
</div>
)
}
function MultipleCommitsSelected() {
const BlankSlateImage = encodePathAsUrl(
__dirname,
'static/empty-no-commit.svg'
)
return (
<div id="multiple-commits-selected" className="panel blankslate">
<img src={BlankSlateImage} className="blankslate-image" />
<div>
<p>Unable to display diff when multiple commits are selected.</p>
<div>You can:</div>
<ul>
<li>Select a single commit to view a diff.</li>
<li>Drag the commits to the branch menu to cherry pick them.</li>
<li>Right click on multiple commits to see options.</li>
</ul>
</div>
</div>
)
}

View file

@ -467,6 +467,10 @@ export class AuthorInput extends React.Component<IAuthorInputProps, {}> {
this.state = {}
}
public focus() {
this.editor?.focus()
}
public componentWillUnmount() {
// Sometimes the completion box seems to fail to register
// the blur event and close. It's hard to reproduce so

View file

@ -250,6 +250,7 @@ export class ConfigureGitUser extends React.Component<
gitHubRepository={null}
isLocal={false}
showUnpushedIndicator={false}
selectedCommits={[dummyCommit]}
/>
</div>
)

View file

@ -48,9 +48,11 @@ export const renderUnmergedFile: React.FunctionComponent<{
*
* - for a merge, this is the tip of the repository
* - for a rebase, this is the base branch that commits are being applied on top
* - for a cherry pick, this is the source branch that the commits come from
*
* If the rebase is started outside Desktop, the details about this branch may
* not be known - the rendered component will handle this fine.
* If the rebase or cherry pick is started outside Desktop, the details about
* this branch may not be known - the rendered component will handle this
* fine.
*/
readonly ourBranch?: string
/**
@ -58,6 +60,8 @@ export const renderUnmergedFile: React.FunctionComponent<{
*
* - for a merge, this is be the branch being merged into the tip of the repository
* - for a rebase, this is the target branch that is having it's history rewritten
* - for a cherrypick, this is the target branch that the commits are being
* applied to.
*
* If the merge is started outside Desktop, the details about this branch may
* not be known - the rendered component will handle this fine.

View file

@ -32,6 +32,9 @@ interface IListRowProps {
/** callback to fire when the row receives a mousedown event */
readonly onRowMouseDown: (index: number, e: React.MouseEvent<any>) => void
/** callback to fire when the row receives a mouseup event */
readonly onRowMouseUp: (index: number, e: React.MouseEvent<any>) => void
/** callback to fire when the row is clicked */
readonly onRowClick: (index: number, e: React.MouseEvent<any>) => void
@ -58,6 +61,10 @@ export class ListRow extends React.Component<IListRowProps, {}> {
this.props.onRowMouseDown(this.props.rowIndex, e)
}
private onRowMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
this.props.onRowMouseUp(this.props.rowIndex, e)
}
private onRowClick = (e: React.MouseEvent<HTMLDivElement>) => {
this.props.onRowClick(this.props.rowIndex, e)
}
@ -97,6 +104,7 @@ export class ListRow extends React.Component<IListRowProps, {}> {
ref={this.props.onRef}
onMouseOver={this.onRowMouseOver}
onMouseDown={this.onRowMouseDown}
onMouseUp={this.onRowMouseUp}
onClick={this.onRowClick}
onKeyDown={this.onRowKeyDown}
style={style}

View file

@ -814,6 +814,7 @@ export class List extends React.Component<IListProps, IListState> {
onRowClick={this.onRowClick}
onRowKeyDown={this.onRowKeyDown}
onRowMouseDown={this.onRowMouseDown}
onRowMouseUp={this.onRowMouseUp}
onRowMouseOver={this.onRowMouseOver}
style={params.style}
tabIndex={tabIndex}
@ -1065,6 +1066,15 @@ export class List extends React.Component<IListProps, IListState> {
event,
})
}
} else if (
this.props.selectionMode === 'range' &&
this.props.selectedRows.length > 1 &&
this.props.selectedRows.includes(row)
) {
// Do nothing. Multiple rows are already selected for a range. We assume
// the user is pressing down on multiple and may desire to start
// dragging. We will invoke the single selection `onRowMouseUp` if they
// let go here and no special keys are being pressed.
} else if (
this.props.selectedRows.length !== 1 ||
(this.props.selectedRows.length === 1 &&
@ -1074,33 +1084,72 @@ export class List extends React.Component<IListProps, IListState> {
* if no special key is pressed, and that the selection is different,
* single selection occurs
*/
if (this.props.onSelectionChanged) {
this.props.onSelectionChanged([row], { kind: 'mouseclick', event })
}
if (this.props.onSelectedRangeChanged) {
this.props.onSelectedRangeChanged(row, row, {
kind: 'mouseclick',
event,
})
}
if (this.props.onSelectedRowChanged) {
const { rowCount } = this.props
if (row < 0 || row >= rowCount) {
log.debug(
`[List.onRowMouseDown] unable to onSelectedRowChanged for row '${row}' as it is outside the bounds of the array [0, ${rowCount}]`
)
return
}
this.props.onSelectedRowChanged(row, { kind: 'mouseclick', event })
}
this.selectSingleRowAfterMouseEvent(row, event)
}
}
}
private onRowMouseUp = (row: number, event: React.MouseEvent<any>) => {
if (!this.canSelectRow(row)) {
return
}
// macOS allow emulating a right click by holding down the ctrl key while
// performing a "normal" click.
const isRightClick =
event.button === 2 || (__DARWIN__ && event.button === 0 && event.ctrlKey)
// prevent the right-click event from changing the selection if not necessary
if (isRightClick && this.props.selectedRows.includes(row)) {
return
}
const multiSelectKey = __DARWIN__ ? event.metaKey : event.ctrlKey
if (
!event.shiftKey &&
!multiSelectKey &&
this.props.selectedRows.length > 1 &&
this.props.selectedRows.includes(row) &&
this.props.selectionMode === 'range'
) {
// No special keys are depressed and multiple rows were selected in a
// range. The onRowMouseDown event was ignored for this scenario because
// the user may desire to started dragging multiple. However, if they let
// go, we want a new single selection to occur.
this.selectSingleRowAfterMouseEvent(row, event)
}
}
private selectSingleRowAfterMouseEvent(
row: number,
event: React.MouseEvent<any>
): void {
if (this.props.onSelectionChanged) {
this.props.onSelectionChanged([row], { kind: 'mouseclick', event })
}
if (this.props.onSelectedRangeChanged) {
this.props.onSelectedRangeChanged(row, row, {
kind: 'mouseclick',
event,
})
}
if (this.props.onSelectedRowChanged) {
const { rowCount } = this.props
if (row < 0 || row >= rowCount) {
log.debug(
`[List.selectSingleRowAfterMouseEvent] unable to onSelectedRowChanged for row '${row}' as it is outside the bounds of the array [0, ${rowCount}]`
)
return
}
this.props.onSelectedRowChanged(row, { kind: 'mouseclick', event })
}
}
private onRowClick = (row: number, event: React.MouseEvent<any>) => {
if (this.canSelectRow(row) && this.props.onRowClick) {
const rowCount = this.props.rowCount

View file

@ -20,7 +20,7 @@ export enum PopoverCaretPosition {
LeftBottom = 'left-bottom',
}
interface IPopoverProps {
readonly onClickOutside: () => void
readonly onClickOutside?: () => void
readonly caretPosition: PopoverCaretPosition
}
@ -54,7 +54,8 @@ export class Popover extends React.Component<IPopoverProps> {
ref !== null &&
ref.parentElement !== null &&
target instanceof Node &&
!ref.parentElement.contains(target)
!ref.parentElement.contains(target) &&
this.props.onClickOutside !== undefined
) {
this.props.onClickOutside()
}

View file

@ -170,6 +170,8 @@ export class LocalChangesOverwrittenDialog extends React.Component<
return 'fetch'
case RetryActionType.Push:
return 'push'
case RetryActionType.CherryPick:
return 'cherry pick'
default:
assertNever(
this.props.retryAction,

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import { Repository } from '../models/repository'
import { Commit } from '../models/commit'
import { Commit, CommitOneLine } from '../models/commit'
import { TipState } from '../models/tip'
import { UiView } from './ui-view'
import { Changes, ChangesSidebar } from './changes'
@ -85,6 +85,12 @@ interface IRepositoryViewProps {
readonly onExitTutorial: () => void
readonly aheadBehindStore: AheadBehindStore
readonly onCherryPick: (
repository: Repository,
commits: ReadonlyArray<CommitOneLine>
) => void
/* Whether or not the user has been introduced to cherry picking feature */
readonly hasShownCherryPickIntro: boolean
}
interface IRepositoryViewState {
@ -226,7 +232,7 @@ export class RepositoryView extends React.Component<
repository={this.props.repository}
isLocalRepository={this.props.state.remote === null}
compareState={this.props.state.compareState}
selectedCommitSha={this.props.state.commitSelection.sha}
selectedCommitShas={this.props.state.commitSelection.shas}
currentBranch={currentBranch}
emoji={this.props.emoji}
commitLookup={this.props.state.commitLookup}
@ -236,9 +242,11 @@ export class RepositoryView extends React.Component<
onRevertCommit={this.onRevertCommit}
onViewCommitOnGitHub={this.props.onViewCommitOnGitHub}
onCompareListScrolled={this.onCompareListScrolled}
onCherryPick={this.props.onCherryPick}
compareListScrollTop={scrollTop}
tagsToPush={this.props.state.tagsToPush}
aheadBehindStore={this.props.aheadBehindStore}
hasShownCherryPickIntro={this.props.hasShownCherryPickIntro}
/>
)
}
@ -324,7 +332,8 @@ export class RepositoryView extends React.Component<
private renderContentForHistory(): JSX.Element {
const { commitSelection } = this.props.state
const sha = commitSelection.sha
const sha =
commitSelection.shas.length === 1 ? commitSelection.shas[0] : null
const selectedCommit =
sha != null ? this.props.state.commitLookup.get(sha) || null : null
@ -349,6 +358,7 @@ export class RepositoryView extends React.Component<
onOpenBinaryFile={this.onOpenBinaryFile}
onChangeImageDiffType={this.onChangeImageDiffType}
onDiffOptionsOpened={this.onDiffOptionsOpened}
areMultipleCommitsSelected={commitSelection.shas.length > 1}
/>
)
}

View file

@ -4,12 +4,17 @@ import { OcticonSymbol, syncClockwise } from '../octicons'
import { Repository } from '../../models/repository'
import { TipState } from '../../models/tip'
import { ToolbarDropdown, DropdownState } from './dropdown'
import { IRepositoryState, isRebaseConflictState } from '../../lib/app-state'
import {
FoldoutType,
IRepositoryState,
isRebaseConflictState,
} from '../../lib/app-state'
import { BranchesContainer, PullRequestBadge } from '../branches'
import { assertNever } from '../../lib/fatal-error'
import { BranchesTab } from '../../models/branches-tab'
import { PullRequest } from '../../models/pull-request'
import classNames from 'classnames'
import { CherryPickStepKind } from '../../models/cherry-pick'
interface IBranchDropdownProps {
readonly dispatcher: Dispatcher
@ -168,12 +173,30 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
showDisclosureArrow={canOpen}
progressValue={progressValue}
buttonClassName={buttonClassName}
onDragOver={this.onDragOver}
>
{this.renderPullRequestInfo()}
</ToolbarDropdown>
)
}
/**
* Method to capture when something is dragged over the branch dropdown.
*/
private onDragOver = (event: React.DragEvent<HTMLDivElement>): void => {
event.preventDefault()
// If the cherry picking state is initiated, we assume the user is
// dragging commits. Therefore, we should open the branch menu.
const { cherryPickState } = this.props.repositoryState
if (
cherryPickState.step !== null &&
cherryPickState.step.kind === CherryPickStepKind.CommitsChosen
) {
this.props.dispatcher.showFoldout({ type: FoldoutType.Branch })
}
}
private renderPullRequestInfo() {
const pr = this.props.currentPullRequest

View file

@ -63,6 +63,12 @@ export interface IToolbarDropdownProps {
*/
readonly dropdownContentRenderer: () => JSX.Element | null
/**
* A function that's called whenever something is dragged over the
* dropdown.
*/
readonly onDragOver?: (event: React.DragEvent<HTMLDivElement>) => void
/**
* An optional classname that will be appended to the default
* class name 'toolbar-button dropdown open|closed'
@ -339,6 +345,7 @@ export class ToolbarDropdown extends React.Component<
onKeyDown={this.props.onKeyDown}
role={this.props.role}
aria-expanded={ariaExpanded}
onDragOver={this.props.onDragOver}
>
{this.renderDropdownContents()}
<ToolbarButton

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -433,7 +433,7 @@ $overlay-background-color: rgba(0, 0, 0, 0.4);
--easing-ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
/** rebase progress bar */
--dialog-rebase-progress-background: #{$green};
--dialog-progress-background: #{$green};
/** merge/rebase status indicators */
--status-pending-color: #{$yellow-700};

View file

@ -1,7 +1,5 @@
@import 'banners/successful-merge';
@import 'banners/merge-conflicts';
@import 'banners/successful-rebase';
@import 'banners/rebase-conflicts';
@import 'banners/successful';
@import 'banners/conflicts';
@import 'banners/update-available';
.banner {

View file

@ -14,6 +14,20 @@
width: 365px;
min-height: 0;
}
.branches-list-item {
height: 100%;
&.dragged-over {
--text-color: var(--box-selected-active-text-color);
--text-secondary-color: var(--box-selected-active-text-color);
color: var(--text-color);
background-color: var(--box-selected-active-background-color);
}
div {
pointer-events: none;
}
}
}
.pull-request-tab {

View file

@ -2,7 +2,7 @@
@import 'dialogs/merge';
@import 'dialogs/merge-conflicts';
@import 'dialogs/rebase';
@import 'dialogs/rebase-progress';
@import 'dialogs/progress-dialog';
@import 'dialogs/abort-merge';
@import 'dialogs/push-needs-pull';
@import 'dialogs/publish-repository';
@ -14,6 +14,7 @@
@import 'dialogs/create-tutorial-repository';
@import 'dialogs/create-fork';
@import 'dialogs/fork-settings';
@import 'dialogs/cherry-pick';
// The styles herein attempt to follow a flow where margins are only applied
// to the bottom of elements (with the exception of the last child). This to

View file

@ -2,3 +2,4 @@
@import 'history/commit-list';
@import 'history/commit-summary';
@import 'history/file-list';
@import 'history/multiple_commits_selected';

View file

@ -1,4 +1,6 @@
#merge-conflicts-banner {
#merge-conflicts-banner,
#rebase-conflicts-banner,
#cherry-pick-conflicts-banner {
.banner-message {
span {
max-width: 100%;

View file

@ -1,17 +0,0 @@
#rebase-conflicts-banner {
.banner-message {
span {
max-width: 100%;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0 1ex;
}
overflow-x: hidden;
display: flex;
flex: 1 1 auto;
}
.alert-icon {
fill: var(--color-conflicted);
}
}

View file

@ -1,28 +0,0 @@
#successful-rebase {
.banner-message {
span {
max-width: 100%;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
overflow-x: hidden;
display: flex;
flex: 1 1 auto;
}
.green-circle {
background-color: var(--color-new);
color: var(--background-color);
border-radius: 50%;
height: 16px;
width: 16px;
display: flex;
justify-content: center;
align-items: center;
margin-right: var(--spacing);
display: flex;
flex-shrink: 0;
flex-grow: 0;
}
}

View file

@ -1,3 +1,6 @@
#cherry-pick-undone,
#successful-cherry-pick,
#successful-rebase,
#successful-merge {
.banner-message {
span {

View file

@ -0,0 +1,51 @@
@import '../../mixins';
dialog#cherry-pick {
width: 450px;
.branches-list {
height: 300px;
}
// We're faking it so that the filter text box appears to reside
// withing the header even though our current component structure
// make it extremely hard to do so.
.dialog-header {
border-bottom: none;
h1 {
font-weight: var(--font-weight-light);
}
}
.dialog-content {
padding: 0;
.filter-field-row {
margin: 0;
border-bottom: var(--base-border);
.filter-list-filter-field {
padding: 0 var(--spacing-double);
padding-bottom: var(--spacing);
}
}
.list-item {
padding: 0 var(--spacing-double);
.filter-list-group-header,
.branches-list-item {
padding: 0;
}
}
}
.dialog-footer {
button[type='submit'] {
height: auto;
width: 100%;
padding: var(--spacing-half);
}
}
}

View file

@ -1,6 +1,7 @@
@import '../../mixins';
dialog#merge-conflicts-list,
dialog#cherry-pick-conflicts-list,
dialog#rebase-conflicts-list {
width: 500px;

View file

@ -1,3 +1,4 @@
dialog#cherry-pick-progress,
dialog#rebase-progress {
progress {
-webkit-appearance: none;
@ -8,7 +9,7 @@ dialog#rebase-progress {
}
&::-webkit-progress-value {
background-color: var(--dialog-rebase-progress-background);
background-color: var(--dialog-progress-background);
// ensure a smooth transition if the rebase goes immediately from 0 to 100%
transition: width 1s ease;
}

View file

@ -7,6 +7,27 @@
flex: 1;
min-height: 0;
.popover-component {
position: absolute;
left: 100%;
top: 73px;
width: 282px;
.call-to-action-bubble {
font-weight: var(--font-weight-semibold);
display: inline-block;
font-size: var(--font-size-xs);
border: 1px solid var(--call-to-action-bubble-border-color);
color: var(--call-to-action-bubble-color);
padding: 1px 5px;
border-radius: var(--border-radius);
margin-left: var(--spacing-third);
}
img {
width: 100%;
}
}
.commit {
display: flex;
flex-direction: row;

View file

@ -0,0 +1,6 @@
#multiple-commits-selected {
text-align: left;
ul {
margin-top: 0px;
}
}

View file

@ -32,6 +32,7 @@ export function createStatus<K extends keyof IStatusResult>(
exists: true,
mergeHeadFound: false,
rebaseInternalState: null,
isCherryPickingHeadFound: false,
workingDirectory: WorkingDirectoryStatus.fromFiles([]),
}

View file

@ -15,7 +15,9 @@ import {
continueCherryPick,
getCherryPickSnapshot,
} from '../../../src/lib/git/cherry-pick'
import { isConflictedFile } from '../../../src/lib/status'
import { Branch } from '../../../src/models/branch'
import { ManualConflictResolution } from '../../../src/models/manual-conflict-resolution'
import { ICherryPickProgress } from '../../../src/models/progress'
import { Repository } from '../../../src/models/repository'
import { AppFileStatusKind } from '../../../src/models/status'
@ -94,6 +96,76 @@ describe('git/cherry-pick', () => {
expect(result).toBe(CherryPickResult.CompletedWithoutError)
})
it('successfully cherry picks a redundant commit', async () => {
result = await cherryPick(repository, featureBranch.tip.sha)
const commits = await getCommits(repository, targetBranch.ref, 5)
expect(commits.length).toBe(2)
expect(result).toBe(CherryPickResult.CompletedWithoutError)
result = await cherryPick(repository, featureBranch.tip.sha)
const commitsAfterRedundant = await getCommits(
repository,
targetBranch.ref,
5
)
expect(commitsAfterRedundant.length).toBe(3)
expect(result).toBe(CherryPickResult.CompletedWithoutError)
})
it('successfully cherry picks an empty commit', async () => {
// add empty commit to feature branch
await switchTo(repository, featureBranchName)
await GitProcess.exec(
['commit', '--allow-empty', '-m', 'Empty Commit'],
repository.path
)
featureBranch = await getBranchOrError(repository, featureBranchName)
await switchTo(repository, targetBranchName)
result = await cherryPick(repository, featureBranch.tip.sha)
const commits = await getCommits(repository, targetBranch.ref, 5)
expect(commits.length).toBe(2)
expect(result).toBe(CherryPickResult.CompletedWithoutError)
})
it('successfully cherry picks an empty commit inside a range', async () => {
const firstCommitSha = featureBranch.tip.sha
// add empty commit to feature branch
await switchTo(repository, featureBranchName)
await GitProcess.exec(
['commit', '--allow-empty', '-m', 'Empty Commit'],
repository.path
)
// add another commit so empty commit will be inside a range
const featureBranchCommitTwo = {
commitMessage: 'Cherry Picked Feature! Number Two',
entries: [
{
path: 'THING_TWO.md',
contents: '# HELLO WORLD! \nTHINGS GO HERE\n',
},
],
}
await makeCommit(repository, featureBranchCommitTwo)
featureBranch = await getBranchOrError(repository, featureBranchName)
await switchTo(repository, targetBranchName)
// cherry picking 3 (on added in setup, empty, featureBranchCommitTwo)
const commitRange = revRangeInclusive(firstCommitSha, featureBranch.tip.sha)
result = await cherryPick(repository, commitRange)
const commits = await getCommits(repository, targetBranch.ref, 5)
expect(commits.length).toBe(4) // original commit + 4 cherry picked
expect(result).toBe(CherryPickResult.CompletedWithoutError)
})
it('successfully cherry picked multiple commits without conflicts', async () => {
// keep reference to the first commit in cherry pick range
const firstCommitSha = featureBranch.tip.sha
@ -127,10 +199,12 @@ describe('git/cherry-pick', () => {
Path.join(repository.path, 'THING.md'),
'# HELLO WORLD! \nTHINGS GO HERE\nFEATURE BRANCH UNDERWAY\n'
)
// This error is not one of the parsed dugite errors
// https://github.com/desktop/dugite/blob/master/lib/errors.ts
// TODO: add to dugite error so we can make use of
// `localChangesOverwrittenHandler` in `error-handler.ts`
// This error should not occur in the wild due to the nature of Desktop's UI
// starting on source branch and having to checkout the target branch.
// During target branch checkout, it will fail before we even get to cherry
// picking. Thus, this scenario from a UI's perspective is already handled.
// No need to add dugite errors to handle it.
result = null
try {
result = await cherryPick(repository, featureBranch.tip.sha)
@ -180,76 +254,6 @@ describe('git/cherry-pick', () => {
expect(result).toBe(null)
})
it('fails to cherry pick an empty commit', async () => {
// add empty commit to feature branch
await switchTo(repository, featureBranchName)
await GitProcess.exec(
['commit', '--allow-empty', '-m', 'Empty Commit'],
repository.path
)
featureBranch = await getBranchOrError(repository, featureBranchName)
await switchTo(repository, targetBranchName)
result = null
try {
result = await cherryPick(repository, featureBranch.tip.sha)
} catch (error) {
expect(error.toString()).toContain('There are no changes to commit')
}
expect(result).toBe(null)
})
it('fails to cherry pick an empty commit inside a range', async () => {
const firstCommitSha = featureBranch.tip.sha
// add empty commit to feature branch
await switchTo(repository, featureBranchName)
await GitProcess.exec(
['commit', '--allow-empty', '-m', 'Empty Commit'],
repository.path
)
// add another commit so empty commit will be inside a range
const featureBranchCommitTwo = {
commitMessage: 'Cherry Picked Feature! Number Two',
entries: [
{
path: 'THING_TWO.md',
contents: '# HELLO WORLD! \nTHINGS GO HERE\n',
},
],
}
await makeCommit(repository, featureBranchCommitTwo)
featureBranch = await getBranchOrError(repository, featureBranchName)
await switchTo(repository, targetBranchName)
try {
const commitRange = revRangeInclusive(
firstCommitSha,
featureBranch.tip.sha
)
result = await cherryPick(repository, commitRange)
} catch (error) {
expect(error.toString()).toContain('There are no changes to commit')
}
expect(result).toBe(null)
})
it('fails to cherry pick a redundant commit', async () => {
result = await cherryPick(repository, featureBranch.tip.sha)
expect(result).toBe(CherryPickResult.CompletedWithoutError)
result = null
try {
result = await cherryPick(repository, featureBranch.tip.sha)
} catch (error) {
expect(error.toString()).toContain('There are no changes to commit')
}
expect(result).toBe(null)
})
describe('cherry picking with conflicts', () => {
beforeEach(async () => {
// In the 'git/cherry-pick' `beforeEach`, we call `createRepository` which
@ -279,7 +283,7 @@ describe('git/cherry-pick', () => {
expect(conflictedFiles).toHaveLength(1)
})
it('successfully continues cherry picking with conflicts after resolving them', async () => {
it('successfully continues cherry picking with conflicts after resolving them by overwriting', async () => {
result = await cherryPick(repository, featureBranch.tip.sha)
expect(result).toBe(CherryPickResult.ConflictsEncountered)
@ -312,6 +316,62 @@ describe('git/cherry-pick', () => {
expect(result).toBe(CherryPickResult.CompletedWithoutError)
})
it('successfully continues cherry picking with conflicts after resolving them manually', async () => {
result = await cherryPick(repository, featureBranch.tip.sha)
expect(result).toBe(CherryPickResult.ConflictsEncountered)
const statusAfterCherryPick = await getStatusOrThrow(repository)
const { files } = statusAfterCherryPick.workingDirectory
// git diff --check warns if conflict markers exist and will exit with
// non-zero status if conflicts found
const diffCheckBefore = await GitProcess.exec(
['diff', '--check'],
repository.path
)
expect(diffCheckBefore.exitCode).toBeGreaterThan(0)
const manualResolutions = new Map<string, ManualConflictResolution>()
for (const file of files) {
if (isConflictedFile(file.status)) {
manualResolutions.set(file.path, ManualConflictResolution.theirs)
}
}
result = await continueCherryPick(repository, files, manualResolutions)
expect(result).toBe(CherryPickResult.CompletedWithoutError)
})
it('successfully continues cherry picking with conflicts after resolving them manually and no changes to commit', async () => {
result = await cherryPick(repository, featureBranch.tip.sha)
expect(result).toBe(CherryPickResult.ConflictsEncountered)
const statusAfterCherryPick = await getStatusOrThrow(repository)
const { files } = statusAfterCherryPick.workingDirectory
// git diff --check warns if conflict markers exist and will exit with
// non-zero status if conflicts found
const diffCheckBefore = await GitProcess.exec(
['diff', '--check'],
repository.path
)
expect(diffCheckBefore.exitCode).toBeGreaterThan(0)
const manualResolutions = new Map<string, ManualConflictResolution>()
for (const file of files) {
if (isConflictedFile(file.status)) {
manualResolutions.set(file.path, ManualConflictResolution.ours)
}
}
result = await continueCherryPick(repository, files, manualResolutions)
expect(result).toBe(CherryPickResult.CompletedWithoutError)
})
it('successfully detects cherry picking with outstanding files not staged', async () => {
result = await cherryPick(repository, featureBranch.tip.sha)
expect(result).toBe(CherryPickResult.ConflictsEncountered)
@ -384,7 +444,7 @@ describe('git/cherry-pick', () => {
result = await cherryPick(repository, 'INVALID REF', p =>
progress.push(p)
)
expect(result).toBe(CherryPickResult.Error)
expect(result).toBe(CherryPickResult.UnableToStart)
})
it('successfully parses progress for a single commit', async () => {
@ -462,7 +522,7 @@ describe('git/cherry-pick', () => {
Path.join(repository.path, 'THING_THREE.md'),
'# Resolve conflicts!'
)
result = await continueCherryPick(repository, files, p =>
result = await continueCherryPick(repository, files, new Map(), p =>
progress.push(p)
)
expect(result).toBe(CherryPickResult.CompletedWithoutError)

View file

@ -4,6 +4,11 @@
"[Fixed] Commit attribution warning is not shown for emails with different capitalization - #11711",
"[Improved] Upgrade embedded Git to v2.29.3 on macOS, and to v2.29.2.windows.4 on Windows - #11755"
],
"2.6.6-beta1": [
"[Added] Add .avif image support - #11625. Thanks @brendonbarreto!",
"[Fixed] Performing remote Git operations in rare cases displays an error message instead of crashing the app - #11694",
"[Improved] Clicking on \"Add Co-Author\" moves the focus to the co-authors text field - #11621"
],
"2.6.5": ["[Fixed] Performing remote Git operations could crash the app"],
"2.6.4": [
"[Added] Allow users to rename and delete branches via a new context menu on branches in the branches list - #5803 #10432",

View file

@ -207,6 +207,8 @@ fatal: could not read Username for 'https://github.com': terminal prompts disabl
Known causes and workarounds:
**If you're experiencing this error, please download the [beta version](https://github.com/desktop/desktop#beta-channel) where it should hopefully be solved.**
- Modifying the `AutoRun` registry entry. To check if this entry has been modified open `Regedit.exe` and navigate to `HKEY_CURRENT_USER\Software\Microsoft\Command Processor\autorun` and `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Command Processor\autorun` to see if there is anything set (sometimes applications will also modify this). See [#6789](https://github.com/desktop/desktop/issues/6879#issuecomment-471042891) and [#2623](https://github.com/desktop/desktop/issues/2623#issuecomment-334305916) for examples of this.
- Special characters in your Windows username like a `&` or `-` can cause this error to be thrown. See [#7064](https://github.com/desktop/desktop/issues/7064) for an example of this. Try installing GitHub Desktop in a new user account to verify if this is the case.

View file

@ -2566,10 +2566,10 @@ bluebird@^3.5.5:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
version "4.11.9"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828"
integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.11.9:
version "4.12.0"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
body-parser@1.19.0:
version "1.19.0"
@ -2656,7 +2656,7 @@ braces@^3.0.1, braces@~3.0.2:
dependencies:
fill-range "^7.0.1"
brorand@^1.0.1:
brorand@^1.0.1, brorand@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=
@ -4154,17 +4154,17 @@ element-closest@^2.0.2:
integrity sha1-cqdAoQdFM4LijfnOXbtajfD5Zuw=
elliptic@^6.0.0:
version "6.5.3"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6"
integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==
version "6.5.4"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==
dependencies:
bn.js "^4.4.0"
brorand "^1.0.1"
bn.js "^4.11.9"
brorand "^1.1.0"
hash.js "^1.0.0"
hmac-drbg "^1.0.0"
inherits "^2.0.1"
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.0"
hmac-drbg "^1.0.1"
inherits "^2.0.4"
minimalistic-assert "^1.0.1"
minimalistic-crypto-utils "^1.0.1"
emittery@^0.7.1:
version "0.7.2"
@ -5563,7 +5563,7 @@ he@1.1.x:
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
integrity sha1-k0EP0hsAlzUVH4howvJx80J+I/0=
hmac-drbg@^1.0.0:
hmac-drbg@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=
@ -7607,7 +7607,7 @@ minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1:
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
minimalistic-crypto-utils@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=