Merge branch 'master' into upgrade-eslint-dependencies

This commit is contained in:
Brendan Forster 2018-11-15 14:16:52 -04:00
commit 8dabdbeeb2
71 changed files with 993 additions and 617 deletions

View file

@ -24,12 +24,3 @@ coverage:
changes: no
comment: off
# not sure what this does, but its the default!
parsers:
gcov:
branch_detection:
conditional: yes
loop: yes
method: no
macro: no

View file

@ -1 +1 @@
8.11.4
8.12.0

2
.nvmrc
View file

@ -1 +1 @@
v8.11.4
v8.12.0

View file

@ -1,2 +1,2 @@
python 2.7
nodejs 8.11.4
nodejs 8.12.0

View file

@ -38,7 +38,7 @@ branches:
language: node_js
node_js:
- '8.11.4'
- '8.12'
cache:
yarn: true

View file

@ -3,7 +3,7 @@
"productName": "GitHub Desktop",
"bundleID": "com.github.GitHubClient",
"companyName": "GitHub, Inc.",
"version": "1.5.0-beta3",
"version": "1.5.1-beta0",
"main": "./main.js",
"repository": {
"type": "git",

View file

@ -5,7 +5,7 @@ import { CrashApp } from './crash-app'
if (!process.env.TEST_ENV) {
/* This is the magic trigger for webpack to go compile
* our sass into css and inject it into the DOM. */
* our sass into css and inject it into the DOM. */
require('./styles/crash.scss')
}

View file

@ -27,22 +27,23 @@ interface IModeDefinition {
/**
* A map between file extensions (including the leading dot, i.e.
* ".jpeg") and the selected mime type to use when highlighting
* that extension as specified in the CodeMirror mode itself.
* ".jpeg") or basenames (i.e. "dockerfile") and the selected mime
* type to use when highlighting that extension as specified in
* the CodeMirror mode itself.
*/
readonly extensions: {
readonly mappings: {
readonly [key: string]: string
}
}
/**
* Array describing all currently supported modes and the file extensions
* Array describing all currently supported extensionModes and the file extensions
* that they cover.
*/
const modes: ReadonlyArray<IModeDefinition> = [
const extensionModes: ReadonlyArray<IModeDefinition> = [
{
install: () => import('codemirror/mode/javascript/javascript'),
extensions: {
mappings: {
'.ts': 'text/typescript',
'.js': 'text/javascript',
'.json': 'application/json',
@ -50,33 +51,33 @@ const modes: ReadonlyArray<IModeDefinition> = [
},
{
install: () => import('codemirror/mode/coffeescript/coffeescript'),
extensions: {
mappings: {
'.coffee': 'text/x-coffeescript',
},
},
{
install: () => import('codemirror/mode/jsx/jsx'),
extensions: {
mappings: {
'.tsx': 'text/typescript-jsx',
'.jsx': 'text/jsx',
},
},
{
install: () => import('codemirror/mode/htmlmixed/htmlmixed'),
extensions: {
mappings: {
'.html': 'text/html',
'.htm': 'text/html',
},
},
{
install: () => import('codemirror/mode/htmlembedded/htmlembedded'),
extensions: {
mappings: {
'.jsp': 'application/x-jsp',
},
},
{
install: () => import('codemirror/mode/css/css'),
extensions: {
mappings: {
'.css': 'text/css',
'.scss': 'text/x-scss',
'.less': 'text/x-less',
@ -84,27 +85,27 @@ const modes: ReadonlyArray<IModeDefinition> = [
},
{
install: () => import('codemirror/mode/vue/vue'),
extensions: {
mappings: {
'.vue': 'text/x-vue',
},
},
{
install: () => import('codemirror/mode/markdown/markdown'),
extensions: {
mappings: {
'.markdown': 'text/x-markdown',
'.md': 'text/x-markdown',
},
},
{
install: () => import('codemirror/mode/yaml/yaml'),
extensions: {
mappings: {
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
},
},
{
install: () => import('codemirror/mode/xml/xml'),
extensions: {
mappings: {
'.xml': 'text/xml',
'.xaml': 'text/xml',
'.csproj': 'text/xml',
@ -116,7 +117,7 @@ const modes: ReadonlyArray<IModeDefinition> = [
},
{
install: () => import('codemirror/mode/clike/clike'),
extensions: {
mappings: {
'.m': 'text/x-objectivec',
'.scala': 'text/x-scala',
'.sc': 'text/x-scala',
@ -132,7 +133,7 @@ const modes: ReadonlyArray<IModeDefinition> = [
},
{
install: () => import('codemirror/mode/mllike/mllike'),
extensions: {
mappings: {
'.ml': 'text/x-ocaml',
'.fs': 'text/x-fsharp',
'.fsx': 'text/x-fsharp',
@ -141,61 +142,61 @@ const modes: ReadonlyArray<IModeDefinition> = [
},
{
install: () => import('codemirror/mode/swift/swift'),
extensions: {
mappings: {
'.swift': 'text/x-swift',
},
},
{
install: () => import('codemirror/mode/shell/shell'),
extensions: {
mappings: {
'.sh': 'text/x-sh',
},
},
{
install: () => import('codemirror/mode/sql/sql'),
extensions: {
mappings: {
'.sql': 'text/x-sql',
},
},
{
install: () => import('codemirror/mode/cypher/cypher'),
extensions: {
mappings: {
'.cql': 'application/x-cypher-query',
},
},
{
install: () => import('codemirror/mode/go/go'),
extensions: {
mappings: {
'.go': 'text/x-go',
},
},
{
install: () => import('codemirror/mode/perl/perl'),
extensions: {
mappings: {
'.pl': 'text/x-perl',
},
},
{
install: () => import('codemirror/mode/php/php'),
extensions: {
mappings: {
'.php': 'application/x-httpd-php',
},
},
{
install: () => import('codemirror/mode/python/python'),
extensions: {
mappings: {
'.py': 'text/x-python',
},
},
{
install: () => import('codemirror/mode/ruby/ruby'),
extensions: {
mappings: {
'.rb': 'text/x-ruby',
},
},
{
install: () => import('codemirror/mode/clojure/clojure'),
extensions: {
mappings: {
'.clj': 'text/x-clojure',
'.cljc': 'text/x-clojure',
'.cljs': 'text/x-clojure',
@ -204,32 +205,32 @@ const modes: ReadonlyArray<IModeDefinition> = [
},
{
install: () => import('codemirror/mode/rust/rust'),
extensions: {
mappings: {
'.rs': 'text/x-rustsrc',
},
},
{
install: () => import('codemirror-mode-elixir'),
extensions: {
mappings: {
'.ex': 'text/x-elixir',
'.exs': 'text/x-elixir',
},
},
{
install: () => import('codemirror/mode/haxe/haxe'),
extensions: {
mappings: {
'.hx': 'text/x-haxe',
},
},
{
install: () => import('codemirror/mode/r/r'),
extensions: {
mappings: {
'.r': 'text/x-rsrc',
},
},
{
install: () => import('codemirror/mode/powershell/powershell'),
extensions: {
mappings: {
'.ps1': 'application/x-powershell',
},
},
@ -237,11 +238,31 @@ const modes: ReadonlyArray<IModeDefinition> = [
/**
* A map between file extensions and mime types, see
* the 'extensions' property on the IModeDefinition interface
* the 'mappings' property on the IModeDefinition interface
* for more information
*/
const extensionMIMEMap = new Map<string, string>()
/**
* Array describing all currently supported basenameModes and the file names
* that they cover.
*/
const basenameModes: ReadonlyArray<IModeDefinition> = [
{
install: () => import('codemirror/mode/dockerfile/dockerfile'),
mappings: {
dockerfile: 'text/x-dockerfile',
},
},
]
/**
* A map between file basenames and mime types, see
* the 'basenames' property on the IModeDefinition interface
* for more information
*/
const basenameMIMEMap = new Map<string, string>()
/**
* A map between mime types and mode definitions. See the
* documentation for the IModeDefinition interface
@ -249,10 +270,17 @@ const extensionMIMEMap = new Map<string, string>()
*/
const mimeModeMap = new Map<string, IModeDefinition>()
for (const mode of modes) {
for (const [extension, mimeType] of Object.entries(mode.extensions)) {
extensionMIMEMap.set(extension, mimeType)
mimeModeMap.set(mimeType, mode)
for (const extensionMode of extensionModes) {
for (const [mapping, mimeType] of Object.entries(extensionMode.mappings)) {
extensionMIMEMap.set(mapping, mimeType)
mimeModeMap.set(mimeType, extensionMode)
}
}
for (const basenameMode of basenameModes) {
for (const [mapping, mimeType] of Object.entries(basenameMode.mappings)) {
basenameMIMEMap.set(mapping, mimeType)
mimeModeMap.set(mimeType, basenameMode)
}
}
@ -293,6 +321,7 @@ async function detectMode(
): Promise<CodeMirror.Mode<{}> | null> {
const mimeType =
extensionMIMEMap.get(request.extension.toLowerCase()) ||
basenameMIMEMap.get(request.basename.toLowerCase()) ||
guessMimeType(request.contents)
if (!mimeType) {

View file

@ -163,6 +163,16 @@ export interface IAppState {
/** The external editor to use when opening repositories */
readonly selectedExternalEditor?: ExternalEditor
/**
* A cached entry representing an external editor found on the user's machine:
*
* - If the `selectedExternalEditor` can be found, choose that
* - Otherwise, if any editors found, this will be set to the first value
* based on the search order in `app/src/lib/editors/{platform}.ts`
* - If no editors found, this will remain `null`
*/
readonly resolvedExternalEditor: ExternalEditor | null
/** What type of visual diff mode we should use to compare images */
readonly imageDiffType: ImageDiffType
@ -223,8 +233,9 @@ export enum RepositorySectionTab {
/**
* Stores information about a merge conflict when it occurs
*/
interface IConflictState {
readonly branch: Branch
export interface IConflictState {
readonly currentBranch: string
readonly currentTip: string
}
export interface IRepositoryState {
@ -525,6 +536,6 @@ export interface ICompareToBranch {
export type CompareAction = IViewHistory | ICompareToBranch
export type SuccessfulMergeBannerState = {
currentBranch: string
theirBranch: string
ourBranch: string
theirBranch?: string
} | null

View file

@ -22,7 +22,7 @@ import {
import { AppStore } from '../stores/app-store'
import { CloningRepository } from '../../models/cloning-repository'
import { Branch } from '../../models/branch'
import { Commit } from '../../models/commit'
import { Commit, ICommitContext } from '../../models/commit'
import { ExternalEditor } from '../../lib/editors'
import { IAPIUser } from '../../lib/api'
import { GitHubRepository } from '../../models/github-repository'
@ -57,7 +57,6 @@ import { BranchesTab } from '../../models/branches-tab'
import { FetchType } from '../../models/fetch'
import { PullRequest } from '../../models/pull-request'
import { IAuthor } from '../../models/author'
import { ITrailer } from '../git/interpret-trailers'
import { isGitRepository } from '../git'
import { ApplicationTheme } from '../../ui/lib/application-theme'
import { TipState } from '../../models/tip'
@ -208,16 +207,9 @@ export class Dispatcher {
*/
public async commitIncludedChanges(
repository: Repository,
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
context: ICommitContext
): Promise<boolean> {
return this.appStore._commitIncludedChanges(
repository,
summary,
description,
trailers
)
return this.appStore._commitIncludedChanges(repository, context)
}
/** Change the file's includedness. */
@ -595,16 +587,28 @@ export class Dispatcher {
return this.appStore._mergeBranch(repository, branch, mergeStatus)
}
/** aborts an in-flight merge and refreshes the repository's status */
public async abortMerge(repository: Repository) {
await this.appStore._abortMerge(repository)
await this.appStore._loadStatus(repository)
}
public createMergeCommit(
/**
* commits an in-flight merge and shows a banner if successful
*
* @param repository
* @param files files to commit. should be all of them in the repository
* @param successfulMergeBannerState information for banner to be displayed if merge is successful
*/
public async createMergeCommit(
repository: Repository,
files: ReadonlyArray<WorkingDirectoryFileChange>
files: ReadonlyArray<WorkingDirectoryFileChange>,
successfulMergeBannerState: SuccessfulMergeBannerState
) {
return this.appStore._createMergeCommit(repository, files)
const result = await this.appStore._createMergeCommit(repository, files)
if (result !== undefined) {
this.appStore._setSuccessfulMergeBannerState(successfulMergeBannerState)
}
}
/** Record the given launch stats. */
@ -1229,12 +1233,15 @@ export class Dispatcher {
return this.appStore._updateCompareForm(repository, newState)
}
public resolveCurrentEditor() {
return this.appStore._resolveCurrentEditor()
}
/**
* Updates the application state to indicate a conflict is in-progress
* as a result of a pull and increments the relevant metric.
*/
public mergeConflictDetectedFromPull() {
this.appStore._mergeConflictDetected()
return this.statsStore.recordMergeConflictFromPull()
}
@ -1243,7 +1250,6 @@ export class Dispatcher {
* as a result of a merge and increments the relevant metric.
*/
public mergeConflictDetectedFromExplicitMerge() {
this.appStore._mergeConflictDetected()
return this.statsStore.recordMergeConflictFromExplicitMerge()
}
@ -1343,18 +1349,4 @@ export class Dispatcher {
public recordAddExistingRepository() {
this.statsStore.recordAddExistingRepository()
}
/**
* Increments the `recordMergeSuccesfulAfterConflicts` metric
*/
public recordMergeSuccesfulAfterConflicts() {
return this.statsStore.recordMergeSuccesAfterConflicts()
}
/**
* Increments the `recordMergeAbortedAfterConflicts` metric
*/
public recordMergeAbortedAfterConflicts() {
return this.statsStore.recordMergeAbortedAfterConflicts()
}
}

View file

@ -303,7 +303,7 @@ export async function mergeConflictHandler(
dispatcher.showPopup({
type: PopupType.MergeConflicts,
repository,
currentBranch: tip.branch.name,
ourBranch: tip.branch.name,
theirBranch,
})

View file

@ -49,14 +49,11 @@ export async function getAvailableEditors(): Promise<
* be found (i.e. it has been removed).
*/
export async function findEditorOrDefault(
name: string | null
): Promise<IFoundEditor<ExternalEditor>> {
name?: string
): Promise<IFoundEditor<ExternalEditor> | null> {
const editors = await getAvailableEditors()
if (editors.length === 0) {
throw new ExternalEditorError(
'No suitable editors installed for GitHub Desktop to launch. Install Atom for your platform and restart GitHub Desktop to try again.',
{ suggestAtom: true }
)
return null
}
if (name) {

View file

@ -64,5 +64,5 @@ export function enableRecurseSubmodulesFlag(): boolean {
/** Should the app use the MergeConflictsDialog component and flow? */
export function enableMergeConflictsDialog(): boolean {
return enableBetaFeatures()
return true
}

View file

@ -1,5 +1,6 @@
import { ITrailer, mergeTrailers } from './git/interpret-trailers'
import { mergeTrailers } from './git/interpret-trailers'
import { Repository } from '../models/repository'
import { ICommitContext } from '../models/commit'
/**
* Formats a summary and a description into a git-friendly
@ -16,10 +17,10 @@ import { Repository } from '../models/repository'
*/
export async function formatCommitMessage(
repository: Repository,
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
context: ICommitContext
) {
const { summary, description, trailers } = context
// Git always trim whitespace at the end of commit messages
// so we concatenate the summary with the description, ensuring
// that they're separated by two newlines. If we don't have a

View file

@ -117,3 +117,38 @@ async function checkIfBranchExistsOnRemote(
)
return result.stdout.length > 0
}
/**
* Finds branches that have a tip equal to the given committish
*
* @param repository within which to execute the command
* @param commitish a sha, HEAD, etc that the branch(es) tip should be
* @returns list branch names. null if an error is encountered
*/
export async function getBranchesPointedAt(
repository: Repository,
commitish: string
): Promise<Array<string> | null> {
const args = [
'branch',
`--points-at=${commitish}`,
'--format=%(refname:short)',
]
// this command has an implicit \n delimiter
const { stdout, exitCode } = await git(
args,
repository.path,
'branchPointedAt',
{
// - 1 is returned if a common ancestor cannot be resolved
// - 129 is returned if ref is malformed
// "warning: ignoring broken ref refs/remotes/origin/master."
successExitCodes: new Set([0, 1, 129]),
}
)
if (exitCode === 1 || exitCode === 129) {
return null
}
// split (and remove trailing element cause its always an empty string)
return stdout.split('\n').slice(0, -1)
}

View file

@ -1,4 +1,4 @@
import { git, GitError, IGitResult } from './core'
import { git, GitError, parseCommitSHA } from './core'
import { stageFiles } from './update-index'
import { Repository } from '../../models/repository'
import { WorkingDirectoryFileChange } from '../../models/status'
@ -55,7 +55,35 @@ export async function createMergeCommit(
await unstageAll(repository)
await stageFiles(repository, files)
const result = await git(
['commit', '--no-edit'],
[
'commit',
// no-edit here ensures the app does not accidentally invoke the user's editor
'--no-edit',
// By default Git merge commits do not contain any commentary (which
// are lines prefixed with `#`). This works because the Git CLI will
// prompt the user to edit the file in `.git/COMMIT_MSG` before
// committing, and then it will run `--cleanup=strip`.
//
// This clashes with our use of `--no-edit` above as Git will now change
// it's behavior to invoke `--cleanup=whitespace` as it did not ask
// the user to edit the COMMIT_MSG as part of creating a commit.
//
// From the docs on git-commit (https://git-scm.com/docs/git-commit) I'll
// quote the relevant section:
// --cleanup=<mode>
// strip
// Strip leading and trailing empty lines, trailing whitespace,
// commentary and collapse consecutive empty lines.
// whitespace
// Same as `strip` except #commentary is not removed.
// default
// Same as `strip` if the message is to be edited. Otherwise `whitespace`.
//
// We should emulate the behavior in this situation because we don't
// let the user view or change the commit message before making the
// commit.
'--cleanup=strip',
],
repository.path,
'createMergeCommit'
)
@ -66,10 +94,6 @@ export async function createMergeCommit(
}
}
function parseCommitSHA(result: IGitResult): string {
return result.stdout.split(']')[0].split(' ')[1]
}
/**
* Commit failures could come from a pre-commit hook rejection.
* So display a bit more context than we otherwise would,

View file

@ -277,3 +277,11 @@ export const gitNetworkArguments: ReadonlyArray<string> = [
'-c',
'credential.helper=',
]
/**
* Returns the SHA of the passed in IGitResult
* @param result
*/
export function parseCommitSHA(result: IGitResult): string {
return result.stdout.split(']')[0].split(' ')[1]
}

View file

@ -1,4 +1,7 @@
import { git } from './core'
import * as FSE from 'fs-extra'
import * as Path from 'path'
import { git, parseCommitSHA } from './core'
import { Repository } from '../../models/repository'
import { Branch } from '../../models/branch'
import { MergeResult, MergeResultKind } from '../../models/merge'
@ -9,9 +12,9 @@ import { spawnAndComplete } from './spawn'
export async function merge(
repository: Repository,
branch: string
): Promise<true> {
await git(['merge', branch], repository.path, 'merge')
return true
): Promise<string> {
const result = await git(['merge', branch], repository.path, 'merge')
return parseCommitSHA(result)
}
/**
@ -90,3 +93,12 @@ export async function mergeTree(
export async function abortMerge(repository: Repository): Promise<void> {
await git(['merge', '--abort'], repository.path, 'abortMerge')
}
/**
* Check the `.git/MERGE_HEAD` file exists in a repository to confirm
* that it is in a conflicted state.
*/
export async function isMergeHeadSet(repository: Repository): Promise<boolean> {
const path = Path.join(repository.path, '.git', 'MERGE_HEAD')
return FSE.pathExists(path)
}

View file

@ -26,6 +26,7 @@ import {
ConflictFileStatus,
ConflictedFile,
} from '../../models/conflicts'
import { isMergeHeadSet } from './merge'
/**
* V8 has a limit on the size of string it can create (~256MB), and unless we want to
@ -54,6 +55,9 @@ export interface IStatusResult {
/** true if the repository exists at the given location */
readonly exists: boolean
/** true if repository is in a conflicted state */
readonly mergeHeadFound: boolean
/** the absolute path to the repository's working directory */
readonly workingDirectory: WorkingDirectoryStatus
}
@ -217,18 +221,22 @@ export async function getStatus(
const workingDirectory = WorkingDirectoryStatus.fromFiles([...files.values()])
const mergeHeadFound = await isMergeHeadSet(repository)
return {
currentBranch,
currentTip,
currentUpstreamBranch,
branchAheadBehind,
exists: true,
mergeHeadFound,
workingDirectory,
}
}
function getConflictStatus(
path: string,
status: FileEntry,
conflictState: ConflictState
): ConflictFileStatus | null {
const { filesWithConflictMarkers, binaryFilePathsInConflicts } = conflictState
@ -249,6 +257,19 @@ function getConflictStatus(
return { kind: 'text', conflictMarkerCount }
}
if (status.kind === 'conflicted') {
const { us, them } = status
const code = them === us ? us : null
const conflictWithoutMarkers =
code !== GitStatusEntry.UpdatedButUnmerged &&
code !== GitStatusEntry.Modified &&
code !== GitStatusEntry.Added
if (conflictWithoutMarkers) {
return { kind: 'text', conflictMarkerCount: null, us, them }
}
}
return null
}
@ -286,7 +307,7 @@ function buildStatusMap(
files.delete(entry.path)
}
const conflictStatus = getConflictStatus(entry.path, conflictState)
const conflictStatus = getConflictStatus(entry.path, status, conflictState)
// for now we just poke at the existing summary
const summary = convertToAppStatus(status, conflictStatus !== null)

View file

@ -43,6 +43,12 @@ export interface IHighlightRequest {
*/
readonly tabSize: number
/**
* The file basename of the path in question as returned
* by node's basename() function.
*/
readonly basename: string
/**
* The file extension of the path in question as returned
* by node's extname() function (i.e. with a leading dot).

View file

@ -12,6 +12,8 @@ const workerUri = encodePathAsUrl(__dirname, 'highlighter.js')
*
* @param contents The actual contents which is to be used for
* highlighting.
* @param basename The file basename of the path in question as returned
* by node's basename() function (i.e. without a leading dot).
* @param extension The file extension of the path in question as returned
* by node's extname() function (i.e. with a leading dot).
* @param tabSize The width of a tab character. Defaults to 4. Used by the
@ -27,6 +29,7 @@ const workerUri = encodePathAsUrl(__dirname, 'highlighter.js')
*/
export function highlight(
contents: string,
basename: string,
extension: string,
tabSize: number,
lines: Array<number>
@ -68,6 +71,7 @@ export function highlight(
const request: IHighlightRequest = {
contents,
basename,
extension,
tabSize,
lines,

View file

@ -185,8 +185,8 @@ async function findHyper(): Promise<string | null> {
const path = commandPieces
? commandPieces[2]
: localAppData != null
? localAppData.concat('\\hyper\\Hyper.exe')
: null // fall back to the launcher in install root
? localAppData.concat('\\hyper\\Hyper.exe')
: null // fall back to the launcher in install root
if (path == null) {
log.debug(

View file

@ -711,7 +711,7 @@ export class StatsStore {
}
/** Record when a conflicted merge was successfully completed by the user */
public async recordMergeSuccesAfterConflicts(): Promise<void> {
public async recordMergeSuccessAfterConflicts(): Promise<void> {
return this.updateDailyMeasures(m => ({
mergeSuccessAfterConflictsCount: m.mergeSuccessAfterConflictsCount + 1,
}))

View file

@ -22,7 +22,7 @@ import {
import { BranchesTab } from '../../models/branches-tab'
import { CloneRepositoryTab } from '../../models/clone-repository-tab'
import { CloningRepository } from '../../models/cloning-repository'
import { Commit } from '../../models/commit'
import { Commit, ICommitContext } from '../../models/commit'
import {
DiffSelection,
DiffSelectionType,
@ -43,7 +43,6 @@ import {
CommittedFileChange,
WorkingDirectoryFileChange,
WorkingDirectoryStatus,
AppFileStatus,
} from '../../models/status'
import { TipState } from '../../models/tip'
import { ICommitMessage } from '../../models/commit-message'
@ -89,6 +88,7 @@ import {
MergeResultStatus,
ComparisonMode,
SuccessfulMergeBannerState,
IConflictState,
} from '../app-state'
import { caseInsensitiveCompare } from '../compare'
import { IGitHubUser } from '../databases/github-user-database'
@ -122,7 +122,6 @@ import {
getWorkingDirectoryDiff,
isCoAuthoredByTrailer,
mergeTree,
ITrailer,
pull as pullRepo,
push as pushRepo,
renameBranch,
@ -131,6 +130,7 @@ import {
appendIgnoreRule,
IStatusResult,
createMergeCommit,
getBranchesPointedAt,
} from '../git'
import {
installGlobalLFSFilters,
@ -161,6 +161,7 @@ import { AheadBehindUpdater } from './helpers/ahead-behind-updater'
import {
enableRepoInfoIndicators,
enableMergeConflictDetection,
enableMergeConflictsDialog,
} from '../feature-flag'
import { MergeResultKind } from '../../models/merge'
import { promiseWithMinimumTimeout } from '../promise'
@ -173,6 +174,7 @@ import { readEmoji } from '../read-emoji'
import { GitStoreCache } from './git-store-cache'
import { MergeConflictsErrorContext } from '../git-error-context'
import { setNumber, setBoolean, getBoolean, getNumber } from '../local-storage'
import { ExternalEditorError } from '../editors/shared'
/**
* As fast-forwarding local branches is proportional to the number of local
@ -268,6 +270,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
private selectedExternalEditor?: ExternalEditor
private resolvedExternalEditor: ExternalEditor | null = null
/** The user's preferred shell. */
private selectedShell = DefaultShell
@ -496,6 +500,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
imageDiffType: this.imageDiffType,
selectedShell: this.selectedShell,
repositoryFilterText: this.repositoryFilterText,
resolvedExternalEditor: this.resolvedExternalEditor,
selectedCloneRepositoryTab: this.selectedCloneRepositoryTab,
selectedBranchesTab: this.selectedBranchesTab,
selectedTheme: this.selectedTheme,
@ -1470,68 +1475,6 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
}
private detectMergeResolution(status: IStatusResult) {
const currentBranchName = status.currentBranch
if (currentBranchName === undefined) {
return
}
const selection = this.getSelectedState()
if (selection === null || selection.type !== SelectionType.Repository) {
return
}
const { tip } = selection.state.branchesState
if (tip.kind !== TipState.Valid) {
return
}
const repository = selection.repository
const repoState = this.repositoryStateCache.get(repository)
const { conflictState } = repoState.changesState
// conflict state being null means that there are no conflicts
if (conflictState === null) {
return
}
const previousBranch = conflictState.branch
// The branch name has changed, so the merge must have been aborted
if (previousBranch.name !== currentBranchName) {
this.statsStore.recordMergeAbortedAfterConflicts()
this.repositoryStateCache.updateChangesState(repository, () => ({
conflictState: null,
}))
this.emitUpdate()
return
}
// are there files that have a conflicted or _resolved_ status?
const workingDirectioryHasConflicts = status.workingDirectory.files.some(
file =>
file.status === AppFileStatus.Conflicted ||
file.status === AppFileStatus.Resolved
)
if (workingDirectioryHasConflicts) {
return
}
if (status.currentTip === previousBranch.tip.sha) {
// if the tip is the same, no merge commit was created
this.statsStore.recordMergeAbortedAfterConflicts()
} else {
this.statsStore.recordMergeSuccesAfterConflicts()
}
this.repositoryStateCache.updateChangesState(repository, state => ({
conflictState: null,
}))
this.emitUpdate()
}
/** This shouldn't be called directly. See `Dispatcher`. */
public async _loadStatus(
repository: Repository,
@ -1544,7 +1487,6 @@ export class AppStore extends TypedBaseStore<IAppState> {
return false
}
this.detectMergeResolution(status)
this.repositoryStateCache.updateChangesState(repository, state => {
// Populate a map for all files in the current working directory state
const filesByID = new Map<string, WorkingDirectoryFileChange>()
@ -1606,6 +1548,53 @@ export class AppStore extends TypedBaseStore<IAppState> {
return { workingDirectory, selectedFileIDs, diff }
})
this.repositoryStateCache.updateChangesState(repository, state => {
const prevConflictState = state.conflictState
const newConflictState = getConflictState(status)
if (prevConflictState == null && newConflictState == null) {
return { conflictState: null }
}
const previousBranchName =
prevConflictState != null ? prevConflictState.currentBranch : null
const currentBranchName =
newConflictState != null ? newConflictState.currentBranch : null
const branchNameChanged =
previousBranchName != null &&
currentBranchName != null &&
previousBranchName !== currentBranchName
// The branch name has changed while remaining conflicted -> the merge must have been aborted
if (branchNameChanged) {
this.statsStore.recordMergeAbortedAfterConflicts()
return { conflictState: newConflictState }
}
const { currentTip } = status
// if the repository is no longer conflicted, what do we think happened?
if (
prevConflictState != null &&
newConflictState == null &&
currentTip != null
) {
const previousTip = prevConflictState.currentTip
if (previousTip !== currentTip) {
this.statsStore.recordMergeSuccessAfterConflicts()
} else {
this.statsStore.recordMergeAbortedAfterConflicts()
}
}
return { conflictState: newConflictState }
})
this._triggerMergeConflictsFlow(repository)
this.emitUpdate()
this.updateChangesDiffForCurrentSelection(repository)
@ -1613,6 +1602,48 @@ export class AppStore extends TypedBaseStore<IAppState> {
return true
}
/** starts the conflict resolution flow, if appropriate */
private async _triggerMergeConflictsFlow(repository: Repository) {
if (!enableMergeConflictsDialog()) {
return
}
const alreadyInFlow =
this.currentPopup !== null &&
(this.currentPopup.type === PopupType.MergeConflicts ||
this.currentPopup.type === PopupType.AbortMerge)
if (alreadyInFlow) {
return
}
const repoState = this.repositoryStateCache.get(repository)
const { conflictState } = repoState.changesState
if (conflictState === null) {
return
}
const possibleTheirsBranches = await getBranchesPointedAt(
repository,
'MERGE_HEAD'
)
// null means we encountered an error
if (possibleTheirsBranches === null) {
return
}
const theirBranch =
possibleTheirsBranches.length === 1
? possibleTheirsBranches[0]
: undefined
const ourBranch = conflictState.currentBranch
this._showPopup({
type: PopupType.MergeConflicts,
repository,
ourBranch,
theirBranch,
})
}
/** This shouldn't be called directly. See `Dispatcher`. */
public async _changeRepositorySection(
repository: Repository,
@ -1728,8 +1759,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
selectableLines
)
const selectedFile = currentlySelectedFile.withSelection(newSelection)
const updatedFiles = changesState.workingDirectory.files.map(
f => (f.id === selectedFile.id ? selectedFile : f)
const updatedFiles = changesState.workingDirectory.files.map(f =>
f.id === selectedFile.id ? selectedFile : f
)
const workingDirectory = WorkingDirectoryStatus.fromFiles(updatedFiles)
@ -1743,9 +1774,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
/** This shouldn't be called directly. See `Dispatcher`. */
public async _commitIncludedChanges(
repository: Repository,
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
context: ICommitContext
): Promise<boolean> {
const state = this.repositoryStateCache.get(repository)
const files = state.changesState.workingDirectory.files
@ -1757,12 +1786,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
const result = await this.isCommitting(repository, () => {
return gitStore.performFailableOperation(async () => {
const message = await formatCommitMessage(
repository,
summary,
description,
trailers
)
const message = await formatCommitMessage(repository, context)
return createCommit(repository, message, selectedFiles)
})
})
@ -1777,7 +1801,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.statsStore.recordPartialCommit()
}
if (trailers != null && trailers.some(isCoAuthoredByTrailer)) {
const { trailers } = context
if (trailers !== undefined && trailers.some(isCoAuthoredByTrailer)) {
this.statsStore.recordCoAuthoredCommit()
}
@ -1824,8 +1849,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
selection: DiffSelection
) {
this.repositoryStateCache.updateChangesState(repository, state => {
const newFiles = state.workingDirectory.files.map(
f => (f.id === file.id ? f.withSelection(selection) : f)
const newFiles = state.workingDirectory.files.map(f =>
f.id === file.id ? f.withSelection(selection) : f
)
const workingDirectory = WorkingDirectoryStatus.fromFiles(newFiles)
@ -3097,7 +3122,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
if (mergeSuccessful && tip.kind === TipState.Valid) {
this._setSuccessfulMergeBannerState({
currentBranch: tip.branch.name,
ourBranch: tip.branch.name,
theirBranch: branch,
})
}
@ -3115,9 +3140,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
public async _createMergeCommit(
repository: Repository,
files: ReadonlyArray<WorkingDirectoryFileChange>
): Promise<void> {
): Promise<string | undefined> {
const gitStore = this.gitStoreCache.get(repository)
await gitStore.performFailableOperation(() =>
return await gitStore.performFailableOperation(() =>
createMergeCommit(repository, files)
)
}
@ -3151,11 +3176,20 @@ export class AppStore extends TypedBaseStore<IAppState> {
/** Open a path to a repository or file using the user's configured editor */
public async _openInExternalEditor(fullPath: string): Promise<void> {
const selectedExternalEditor =
this.getState().selectedExternalEditor || null
const { selectedExternalEditor } = this.getState()
try {
const match = await findEditorOrDefault(selectedExternalEditor)
if (match === null) {
this.emitError(
new ExternalEditorError(
'No suitable editors installed for GitHub Desktop to launch. Install Atom for your platform and restart GitHub Desktop to try again.',
{ suggestAtom: true }
)
)
return
}
await launchExternalEditor(fullPath, match)
} catch (error) {
this.emitError(error)
@ -3986,33 +4020,13 @@ export class AppStore extends TypedBaseStore<IAppState> {
return Promise.resolve()
}
/**
* Sets conflict state with a non-null value
*
* The presence of a non-null value signifies
* that the repository is in a conflicted state
*/
public _mergeConflictDetected() {
const selection = this.getSelectedState()
if (selection === null || selection.type !== SelectionType.Repository) {
return
public async _resolveCurrentEditor() {
const match = await findEditorOrDefault(this.selectedExternalEditor)
const resolvedExternalEditor = match != null ? match.editor : null
if (this.resolvedExternalEditor !== resolvedExternalEditor) {
this.resolvedExternalEditor = resolvedExternalEditor
this.emitUpdate()
}
const { tip } = selection.state.branchesState
if (tip.kind !== TipState.Valid) {
return
}
const repository = selection.repository
this.repositoryStateCache.updateChangesState(repository, () => ({
conflictState: {
branch: tip.branch,
},
}))
this.emitUpdate()
}
}
@ -4049,3 +4063,22 @@ function getBehindOrDefault(aheadBehind: IAheadBehind | null): number {
return aheadBehind.behind
}
/**
* Convert the received status information into a conflict state
*/
function getConflictState(status: IStatusResult): IConflictState | null {
if (!status.mergeHeadFound) {
return null
}
const { currentBranch, currentTip } = status
if (currentBranch == null || currentTip == null) {
return null
}
return {
currentBranch,
currentTip,
}
}

View file

@ -515,11 +515,10 @@ export class GitStore extends BaseStore {
public async undoCommit(commit: Commit): Promise<void> {
// For an initial commit, just delete the reference but leave HEAD. This
// will make the branch unborn again.
const success = await this.performFailableOperation(
() =>
commit.parentSHAs.length === 0
? this.undoFirstCommit(this.repository)
: reset(this.repository, GitResetMode.Mixed, commit.parentSHAs[0])
const success = await this.performFailableOperation(() =>
commit.parentSHAs.length === 0
? this.undoFirstCommit(this.repository)
: reset(this.repository, GitResetMode.Mixed, commit.parentSHAs[0])
)
if (success === undefined) {
@ -561,12 +560,10 @@ export class GitStore extends BaseStore {
// git-interpret-trailers is really only made for working
// with full commit messages so let's start with that
const message = await formatCommitMessage(
repository,
commit.summary,
commit.body,
[]
)
const message = await formatCommitMessage(repository, {
summary: commit.summary,
description: commit.body,
})
// Next we extract any co-authored-by trailers we
// can find. We use interpret-trailers for this
@ -1103,7 +1100,7 @@ export class GitStore extends BaseStore {
}
/** Merge the named branch into the current branch. */
public merge(branch: string): Promise<true | undefined> {
public merge(branch: string): Promise<string | undefined> {
return this.performFailableOperation(() => merge(this.repository, branch), {
gitContext: {
kind: 'merge',

View file

@ -0,0 +1,40 @@
import { Commit } from '../models/commit'
import { GitHubRepository } from '../models/github-repository'
import { getDotComAPIEndpoint } from './api'
/**
* Best-effort attempt to figure out if this commit was committed using
* the web flow on GitHub.com or GitHub Enterprise. Web flow
* commits (such as PR merges) will have a special GitHub committer
* with a noreply email address.
*
* For GitHub.com we can be spot on but for GitHub Enterprise it's
* possible we could fail if they've set up a custom smtp host
* that doesn't correspond to the hostname.
*/
export function isWebFlowCommitter(
commit: Commit,
gitHubRepository: GitHubRepository
) {
if (!gitHubRepository) {
return false
}
const endpoint = gitHubRepository.owner.endpoint
const { name, email } = commit.committer
if (
endpoint === getDotComAPIEndpoint() &&
name === 'GitHub' &&
email === 'noreply@github.com'
) {
return true
}
if (name === 'GitHub Enterprise') {
const host = new URL(endpoint).host.toLowerCase()
return email.endsWith(`@${host}`)
}
return false
}

View file

@ -373,8 +373,8 @@ export function buildDefaultMenu({
const showLogsLabel = __DARWIN__
? 'Show Logs in Finder'
: __WIN32__
? 'S&how logs in Explorer'
: 'S&how logs in your File Manager'
? 'S&how logs in Explorer'
: 'S&how logs in your File Manager'
const showLogsItem: Electron.MenuItemConstructorOptions = {
label: showLogsLabel,
@ -506,9 +506,8 @@ function zoom(direction: ZoomDirection): ClickHandler {
// zoom factors the value is referring to.
const currentZoom = findClosestValue(zoomFactors, rawZoom)
const nextZoomLevel = zoomFactors.find(
f =>
direction === ZoomDirection.In ? f > currentZoom : f < currentZoom
const nextZoomLevel = zoomFactors.find(f =>
direction === ZoomDirection.In ? f > currentZoom : f < currentZoom
)
// If we couldn't find a zoom level (likely due to manual manipulation

View file

@ -5,6 +5,7 @@ import { GitAuthor } from './git-author'
import { generateGravatarUrl } from '../lib/gravatar'
import { getDotComAPIEndpoint } from '../lib/api'
import { GitHubRepository } from './github-repository'
import { isWebFlowCommitter } from '../lib/web-flow-committer'
/** The minimum properties we need in order to display a user's avatar. */
export interface IAvatarUser {
@ -83,10 +84,10 @@ export function getAvatarUsersForCommit(
)
)
const isWebFlowCommitter =
gitHubRepository !== null && commit.isWebFlowCommitter(gitHubRepository)
const webFlowCommitter =
gitHubRepository !== null && isWebFlowCommitter(commit, gitHubRepository)
if (!commit.authoredByCommitter && !isWebFlowCommitter) {
if (!commit.authoredByCommitter && !webFlowCommitter) {
avatarUsers.push(
getAvatarUserFromAuthor(gitHubRepository, gitHubUsers, commit.committer)
)

View file

@ -1,8 +1,22 @@
import { CommitIdentity } from './commit-identity'
import { ITrailer, isCoAuthoredByTrailer } from '../lib/git/interpret-trailers'
import { GitAuthor } from './git-author'
import { GitHubRepository } from './github-repository'
import { getDotComAPIEndpoint } from '../lib/api'
/** Grouping of information required to create a commit */
export interface ICommitContext {
/**
* The summary of the commit message (required)
*/
readonly summary: string
/**
* Additional details for the commit message (optional)
*/
readonly description: string | null
/**
* An optional array of commit trailers (for example Co-Authored-By trailers) which will be appended to the commit message in accordance with the Git trailer configuration.
*/
readonly trailers?: ReadonlyArray<ITrailer>
}
/**
* Extract any Co-Authored-By trailers from an array of arbitrary
@ -64,38 +78,4 @@ export class Commit {
this.author.name === this.committer.name &&
this.author.email === this.committer.email
}
/**
* Best-effort attempt to figure out if this commit was committed using
* the web flow on GitHub.com or GitHub Enterprise. Web flow
* commits (such as PR merges) will have a special GitHub committer
* with a noreply email address.
*
* For GitHub.com we can be spot on but for GitHub Enterprise it's
* possible we could fail if they've set up a custom smtp host
* that doesn't correspond to the hostname.
*/
public isWebFlowCommitter(gitHubRepository: GitHubRepository) {
if (!gitHubRepository) {
return false
}
const endpoint = gitHubRepository.owner.endpoint
const { name, email } = this.committer
if (
endpoint === getDotComAPIEndpoint() &&
name === 'GitHub' &&
email === 'noreply@github.com'
) {
return true
}
if (this.committer.name === 'GitHub Enterprise') {
const host = new URL(endpoint).host.toLowerCase()
return email.endsWith(`@${host}`)
}
return false
}
}

View file

@ -10,8 +10,15 @@ import { GitStatusEntry, UnmergedEntry } from './status'
export type ConflictFileStatus =
| {
readonly kind: 'text'
/** This number should be greater than zero */
readonly conflictMarkerCount: number
/**
* This number should be greater than zero
* or null if the file has a non-markered conflict (like added vs removed)
*/
readonly conflictMarkerCount: number | null
/** The state of the file in the current branch */
readonly us?: GitStatusEntry
/** THe state of the file in the other branch */
readonly them?: GitStatusEntry
}
| {
readonly kind: 'binary'

View file

@ -124,12 +124,12 @@ export type Popup =
| {
type: PopupType.MergeConflicts
repository: Repository
currentBranch: string
theirBranch: string
ourBranch: string
theirBranch?: string
}
| {
type: PopupType.AbortMerge
repository: Repository
currentBranch: string
theirBranch: string
ourBranch: string
theirBranch?: string
}

View file

@ -7,28 +7,19 @@ import { ConflictFileStatus } from './conflicts'
* The status entry code as reported by Git.
*/
export enum GitStatusEntry {
// M
Modified,
// A
Added,
// D
Deleted,
// R
Renamed,
// C
Copied,
// .
Unchanged,
// ?
Untracked,
// !
Ignored,
// U
Modified = 'M',
Added = 'A',
Deleted = 'D',
Renamed = 'R',
Copied = 'C',
Unchanged = '.',
Untracked = '?',
Ignored = '!',
//
// While U is a valid code here, we currently mark conflicts as "Modified"
// in the application - this will likely be something we need to revisit
// down the track as we improve our merge conflict experience
UpdatedButUnmerged,
UpdatedButUnmerged = 'U',
}
/** The file status as represented in GitHub Desktop. */

View file

@ -11,8 +11,8 @@ interface IAbortMergeWarningProps {
readonly dispatcher: Dispatcher
readonly repository: Repository
readonly onDismissed: () => void
readonly currentBranch: string
readonly theirBranch: string
readonly ourBranch: string
readonly theirBranch?: string
}
const titleString = 'Confirm abort merge'
@ -42,11 +42,43 @@ export class AbortMergeWarning extends React.Component<
this.props.dispatcher.showPopup({
type: PopupType.MergeConflicts,
repository: this.props.repository,
currentBranch: this.props.currentBranch,
ourBranch: this.props.ourBranch,
theirBranch: this.props.theirBranch,
})
}
private renderTextContent(ourBranch: string, theirBranch?: string) {
let firstParagraph
if (theirBranch !== undefined) {
firstParagraph = (
<p>
{'Are you sure you want to abort merging '}
<strong>{theirBranch}</strong>
{' into '}
<strong>{ourBranch}</strong>?
</p>
)
} else {
firstParagraph = (
<p>
{'Are you sure you want to abort merging into '}
<strong>{ourBranch}</strong>?
</p>
)
}
return (
<div className="column-left">
{firstParagraph}
<p>
Aborting this merge will take you back to the pre-merge state and the
conflicts you've already resolved will still be present.
</p>
</div>
)
}
public render() {
return (
<Dialog
@ -58,18 +90,7 @@ export class AbortMergeWarning extends React.Component<
>
<DialogContent className="content-wrapper">
<Octicon symbol={OcticonSymbol.alert} />
<div className="column-left">
<p>
{'Are you sure you want to abort merging '}
<strong>{this.props.theirBranch}</strong>
{' into '}
<strong>{this.props.currentBranch}</strong>?
</p>
<p>
Aborting this merge will take you back to the pre-merge state and
the conflicts you've already resolved will still be present.
</p>
</div>
{this.renderTextContent(this.props.ourBranch, this.props.theirBranch)}
</DialogContent>
<DialogFooter>
<ButtonGroup>

View file

@ -1339,15 +1339,13 @@ export class App extends React.Component<IAppProps, IAppState> {
) {
return null
}
const workingDirectoryStatus =
selectedState.state.changesState.workingDirectory
// double check that this repository is actually in merge
const isInConflictedMerge = workingDirectoryStatus.files.some(
file =>
file.status === AppFileStatus.Conflicted ||
file.status === AppFileStatus.Resolved
)
if (!isInConflictedMerge) {
const {
workingDirectory,
conflictState,
} = selectedState.state.changesState
if (conflictState === null) {
return null
}
@ -1355,12 +1353,12 @@ export class App extends React.Component<IAppProps, IAppState> {
<MergeConflictsDialog
dispatcher={this.props.dispatcher}
repository={popup.repository}
status={workingDirectoryStatus}
workingDirectory={workingDirectory}
onDismissed={this.onPopupDismissed}
openFileInExternalEditor={this.openFileInExternalEditor}
externalEditorName={this.state.selectedExternalEditor}
resolvedExternalEditor={this.state.resolvedExternalEditor}
openRepositoryInShell={this.openInShell}
currentBranch={popup.currentBranch}
ourBranch={popup.ourBranch}
theirBranch={popup.theirBranch}
/>
)
@ -1398,7 +1396,7 @@ export class App extends React.Component<IAppProps, IAppState> {
dispatcher={this.props.dispatcher}
repository={popup.repository}
onDismissed={this.onPopupDismissed}
currentBranch={popup.currentBranch}
ourBranch={popup.ourBranch}
theirBranch={popup.theirBranch}
/>
)
@ -1756,14 +1754,24 @@ export class App extends React.Component<IAppProps, IAppState> {
// we currently only render one banner at a time
private renderBanner(): JSX.Element | null {
let banner = null
if (this.state.successfulMergeBannerState !== null) {
return this.renderSuccessfulMergeBanner(
banner = this.renderSuccessfulMergeBanner(
this.state.successfulMergeBannerState
)
} else if (this.state.isUpdateAvailableBannerVisible) {
return this.renderUpdateBanner()
banner = this.renderUpdateBanner()
}
return null
return (
<CSSTransitionGroup
transitionName="banner"
component="div"
transitionEnterTimeout={500}
transitionLeaveTimeout={400}
>
{banner}
</CSSTransitionGroup>
)
}
private renderUpdateBanner() {
@ -1775,6 +1783,7 @@ export class App extends React.Component<IAppProps, IAppState> {
newRelease={updateStore.state.newRelease}
releaseNotesLink={releaseNotesUri}
onDismissed={this.onUpdateAvailableDismissed}
key={'update-available'}
/>
)
}
@ -1787,9 +1796,10 @@ export class App extends React.Component<IAppProps, IAppState> {
}
return (
<SuccessfulMerge
currentBranch={successfulMergeBannerState.currentBranch}
ourBranch={successfulMergeBannerState.ourBranch}
theirBranch={successfulMergeBannerState.theirBranch}
onDismissed={this.onSuccessfulMergeDismissed}
key={'successful-merge'}
/>
)
}

View file

@ -2,8 +2,8 @@ import * as React from 'react'
import { Octicon, OcticonSymbol } from '../octicons'
interface ISuccessfulMergeProps {
readonly currentBranch: string
readonly theirBranch: string
readonly ourBranch: string
readonly theirBranch?: string
readonly onDismissed: () => void
}
@ -13,19 +13,30 @@ export class SuccessfulMerge extends React.Component<
> {
private timeoutId: NodeJS.Timer | null = null
private renderMessage(ourBranch: string, theirBranch?: string) {
return theirBranch !== undefined ? (
<span>
{'Successfully merged '}
<strong>{theirBranch}</strong>
{' into '}
<strong>{ourBranch}</strong>
</span>
) : (
<span>
{'Successfully merged into '}
<strong>{ourBranch}</strong>
</span>
)
}
public render() {
return (
<div id="successful-merge" className="active">
<div id="successful-merge">
<div className="green-circle">
<Octicon className="check-icon" symbol={OcticonSymbol.check} />
</div>
<div className="banner-message">
<span>
{'Successfully merged '}
<strong>{this.props.theirBranch}</strong>
{' into '}
<strong>{this.props.currentBranch}</strong>
</span>
{this.renderMessage(this.props.ourBranch, this.props.theirBranch)}
</div>
<div className="close">
<a onClick={this.dismiss}>
@ -39,7 +50,7 @@ export class SuccessfulMerge extends React.Component<
public componentDidMount = () => {
this.timeoutId = setTimeout(() => {
this.dismiss()
}, 3250)
}, 5000)
}
public componentWillUnmount = () => {

View file

@ -32,8 +32,8 @@ export class BranchListItem extends React.Component<IBranchListItemProps, {}> {
const infoTitle = isCurrentBranch
? 'Current branch'
: lastCommitDate
? lastCommitDate.toString()
: ''
? lastCommitDate.toString()
: ''
return (
<div className="branches-list-item">
<Octicon className="icon" symbol={icon} />

View file

@ -89,7 +89,7 @@ export class BranchesContainer extends React.Component<
<Row className="merge-button-row">
<Button className="merge-button" onClick={this.onMergeClick}>
<Octicon className="icon" symbol={OcticonSymbol.gitMerge} />
<span title={`Commit to ${branchName}`}>
<span title={`Merge a branch into ${branchName}`}>
Choose a branch to merge into <strong>{branchName}</strong>
</span>
</Button>
@ -211,6 +211,7 @@ export class BranchesContainer extends React.Component<
}
private onMergeClick = () => {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
this.props.dispatcher.showPopup({
type: PopupType.MergeBranch,
repository: this.props.repository,

View file

@ -3,7 +3,6 @@ import * as Path from 'path'
import { IGitHubUser } from '../../lib/databases'
import { Dispatcher } from '../../lib/dispatcher'
import { ITrailer } from '../../lib/git/interpret-trailers'
import { IMenuItem } from '../../lib/menu-item'
import { revealInFileManager } from '../../lib/app-shell'
import {
@ -32,6 +31,7 @@ import { showContextualMenu } from '../main-process-proxy'
import { arrayEquals } from '../../lib/equality'
import { clipboard } from 'electron'
import { basename } from 'path'
import { ICommitContext } from '../../models/commit'
const RowHeight = 29
@ -44,11 +44,7 @@ interface IChangesListProps {
readonly onFileSelectionChanged: (rows: ReadonlyArray<number>) => void
readonly onIncludeChanged: (path: string, include: boolean) => void
readonly onSelectAll: (selectAll: boolean) => void
readonly onCreateCommit: (
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
) => Promise<boolean>
readonly onCreateCommit: (context: ICommitContext) => Promise<boolean>
readonly onDiscardChanges: (file: WorkingDirectoryFileChange) => void
readonly askForConfirmationOnDiscardChanges: boolean
readonly onDiscardAllChanges: (
@ -165,8 +161,8 @@ export class ChangesList extends React.Component<
selection === DiffSelectionType.All
? true
: selection === DiffSelectionType.None
? false
: null
? false
: null
return (
<ChangedFile
@ -237,8 +233,8 @@ export class ChangesList extends React.Component<
? `Discard Changes`
: `Discard changes`
: __DARWIN__
? `Discard ${files.length} Selected Changes`
: `Discard ${files.length} selected changes`
? `Discard ${files.length} Selected Changes`
: `Discard ${files.length} selected changes`
return this.props.askForConfirmationOnDiscardChanges ? `${label}` : label
}

View file

@ -19,10 +19,10 @@ import { AuthorInput } from '../lib/author-input'
import { FocusContainer } from '../lib/focus-container'
import { showContextualMenu } from '../main-process-proxy'
import { Octicon, OcticonSymbol } from '../octicons'
import { ITrailer } from '../../lib/git/interpret-trailers'
import { IAuthor } from '../../models/author'
import { IMenuItem } from '../../lib/menu-item'
import { shallowEquals } from '../../lib/equality'
import { ICommitContext } from '../../models/commit'
const addAuthorIcon = new OcticonSymbol(
12,
@ -34,11 +34,7 @@ const addAuthorIcon = new OcticonSymbol(
)
interface ICommitMessageProps {
readonly onCreateCommit: (
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
) => Promise<boolean>
readonly onCreateCommit: (context: ICommitContext) => Promise<boolean>
readonly branch: string | null
readonly commitAuthor: CommitIdentity | null
readonly gitHubUser: IGitHubUser | null
@ -181,14 +177,18 @@ export class CommitMessage extends React.Component<
const trailers = this.getCoAuthorTrailers()
const commitCreated = await this.props.onCreateCommit(
// allow single file commit without summary
const summaryOrPlaceholder =
this.props.singleFileCommit && !this.state.summary
? this.props.placeholder
: summary,
: summary
const commitContext = {
summary: summaryOrPlaceholder,
description,
trailers
)
trailers,
}
const commitCreated = await this.props.onCreateCommit(commitContext)
if (commitCreated) {
this.clearCommitMessage()
@ -282,8 +282,8 @@ export class CommitMessage extends React.Component<
? 'Remove Co-Authors'
: 'Remove co-authors'
: __DARWIN__
? 'Add Co-Authors'
: 'Add co-authors'
? 'Add Co-Authors'
: 'Add co-authors'
}
private getAddRemoveCoAuthorsMenuItem(): IMenuItem {

View file

@ -20,8 +20,8 @@ export class NoChanges extends React.Component<INoChangesProps, {}> {
const opener = __DARWIN__
? 'Finder'
: __WIN32__
? 'Explorer'
: 'your File Manager'
? 'Explorer'
: 'your File Manager'
return (
<div className="panel blankslate" id="no-changes">
<img src={BlankSlateImage} className="blankslate-image" />

View file

@ -9,7 +9,7 @@ import { Dispatcher } from '../../lib/dispatcher'
import { IGitHubUser } from '../../lib/databases'
import { IssuesStore, GitHubUserStore } from '../../lib/stores'
import { CommitIdentity } from '../../models/commit-identity'
import { Commit } from '../../models/commit'
import { Commit, ICommitContext } from '../../models/commit'
import { UndoCommit } from './undo-commit'
import {
IAutocompletionProvider,
@ -21,7 +21,6 @@ import { ClickSource } from '../lib/list'
import { WorkingDirectoryFileChange } from '../../models/status'
import { CSSTransitionGroup } from 'react-transition-group'
import { openFile } from '../../lib/open-file'
import { ITrailer } from '../../lib/git/interpret-trailers'
import { Account } from '../../models/account'
import { PopupType } from '../../models/popup'
@ -114,16 +113,10 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
}
}
private onCreateCommit = (
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
): Promise<boolean> => {
private onCreateCommit = (context: ICommitContext): Promise<boolean> => {
return this.props.dispatcher.commitIncludedChanges(
this.props.repository,
summary,
description,
trailers
context
)
}

View file

@ -161,7 +161,7 @@ export class CloneGithubRepository extends React.Component<
selectedItem={this.state.selectedItem}
renderItem={this.renderItem}
renderGroupHeader={this.renderGroupHeader}
onItemClick={this.onItemClicked}
onSelectionChanged={this.onSelectionChanged}
invalidationProps={this.state.repositories}
groups={this.state.repositories}
filterText={this.state.filterText}
@ -183,9 +183,9 @@ export class CloneGithubRepository extends React.Component<
this.setState({ filterText })
}
private onItemClicked = (item: IClonableRepositoryListItem) => {
private onSelectionChanged = (item: IClonableRepositoryListItem | null) => {
this.setState({ selectedItem: item })
this.props.onGitHubRepositorySelected(item.url)
this.props.onGitHubRepositorySelected(item != null ? item.url : '')
}
private onChooseDirectory = async () => {

View file

@ -179,6 +179,7 @@ export async function highlightContents(
const [oldTokens, newTokens] = await Promise.all([
highlight(
oldContents.toString('utf8'),
Path.basename(file.oldPath || file.path),
Path.extname(file.oldPath || file.path),
tabSize,
lineFilters.oldLineFilter
@ -188,6 +189,7 @@ export async function highlightContents(
}),
highlight(
newContents.toString('utf8'),
Path.basename(file.path),
Path.extname(file.path),
tabSize,
lineFilters.newLineFilter

View file

@ -194,7 +194,7 @@ export class CommitSummary extends React.Component<
const expanded = this.props.isExpanded
const onClick = expanded ? this.onCollapse : this.onExpand
const icon = expanded ? OcticonSymbol.unfold : OcticonSymbol.fold
const icon = expanded ? OcticonSymbol.fold : OcticonSymbol.unfold
return (
<a onClick={onClick} className="expander">

View file

@ -80,7 +80,7 @@ const startTime = performance.now()
if (!process.env.TEST_ENV) {
/* This is the magic trigger for webpack to go compile
* our sass into css and inject it into the DOM. */
* our sass into css and inject it into the DOM. */
require('../../styles/desktop.scss')
}

View file

@ -3,6 +3,7 @@ import * as React from 'react'
import { CommitIdentity } from '../../models/commit-identity'
import { GitAuthor } from '../../models/git-author'
import { GitHubRepository } from '../../models/github-repository'
import { isWebFlowCommitter } from '../../lib/web-flow-committer'
interface ICommitAttributionProps {
/**
@ -80,7 +81,7 @@ export class CommitAttribution extends React.Component<
!commit.authoredByCommitter &&
!(
this.props.gitHubRepository !== null &&
commit.isWebFlowCommitter(this.props.gitHubRepository)
isWebFlowCommitter(commit, this.props.gitHubRepository)
)
return (

View file

@ -10,8 +10,8 @@ export const DefaultEditorLabel = __DARWIN__
export const RevealInFileManagerLabel = __DARWIN__
? 'Reveal in Finder'
: __WIN32__
? 'Show in Explorer'
: 'Show in your File Manager'
? 'Show in Explorer'
: 'Show in your File Manager'
export const TrashNameLabel = __DARWIN__ ? 'Trash' : 'Recycle Bin'

View file

@ -21,13 +21,51 @@ import { LinkButton } from '../lib/link-button'
interface IMergeConflictsDialogProps {
readonly dispatcher: Dispatcher
readonly repository: Repository
readonly status: WorkingDirectoryStatus
readonly workingDirectory: WorkingDirectoryStatus
readonly onDismissed: () => void
readonly openFileInExternalEditor: (path: string) => void
readonly externalEditorName?: string
readonly resolvedExternalEditor: string | null
readonly openRepositoryInShell: (repository: Repository) => void
readonly currentBranch: string
readonly theirBranch: string
readonly ourBranch: string
/* `undefined` when we didn't know the branch at the beginning of this flow */
readonly theirBranch?: string
}
/**
* Calculates the number of merge conclicts in a file from the number of markers
* divides by three and rounds up since each conflict is indicated by three separate markers
* (`<<<<<`, `>>>>>`, and `=====`)
* @param conflictMarkers number of conflict markers in a file
*/
function calculateConflicts(conflictMarkers: number) {
return Math.ceil(conflictMarkers / 3)
}
/** Filter working directory changes for conflicted or resolved files */
function getUnmergedFiles(status: WorkingDirectoryStatus) {
return status.files.filter(
file =>
file.status === AppFileStatus.Conflicted ||
file.status === AppFileStatus.Resolved
)
}
function editorButtonString(editorName: string | null): string {
const defaultEditorString = 'editor'
return `Open in ${editorName || defaultEditorString}`
}
function editorButtonTooltip(editorName: string | null): string | undefined {
if (editorName !== null) {
// no need to render a tooltip if we have a known editor
return
}
if (__DARWIN__) {
return `No editor configured in Preferences > Advanced`
} else {
return `No editor configured in Options > Advanced`
}
}
const submitButtonString = 'Commit merge'
@ -40,13 +78,21 @@ export class MergeConflictsDialog extends React.Component<
IMergeConflictsDialogProps,
{}
> {
public async componentDidMount() {
this.props.dispatcher.resolveCurrentEditor()
}
/**
* commits the merge displays the repository changes tab and dismisses the modal
*/
private onSubmit = async () => {
await this.props.dispatcher.createMergeCommit(
this.props.repository,
this.props.status.files
this.props.workingDirectory.files,
{
ourBranch: this.props.ourBranch,
theirBranch: this.props.theirBranch,
}
)
this.props.dispatcher.setCommitMessage(this.props.repository, null)
this.props.dispatcher.changeRepositorySection(
@ -60,7 +106,7 @@ export class MergeConflictsDialog extends React.Component<
* dismisses the modal and shows the abort merge warning modal
*/
private onCancel = async () => {
const anyResolvedFiles = this.getUnmergedFiles().some(
const anyResolvedFiles = getUnmergedFiles(this.props.workingDirectory).some(
f => f.status === AppFileStatus.Resolved
)
if (!anyResolvedFiles) {
@ -71,41 +117,31 @@ export class MergeConflictsDialog extends React.Component<
this.props.dispatcher.showPopup({
type: PopupType.AbortMerge,
repository: this.props.repository,
currentBranch: this.props.currentBranch,
ourBranch: this.props.ourBranch,
theirBranch: this.props.theirBranch,
})
}
}
/**
* Calculates the number of merge conclicts in a file from the number of markers
* divides by three and rounds up since each conflict is indicated by three separate markers
* (`<<<<<`, `>>>>>`, and `=====`)
* @param conflictMarkers number of conflict markers in a file
*/
private calculateConflicts(conflictMarkers: number) {
return Math.ceil(conflictMarkers / 3)
}
private renderHeaderTitle(
currentBranchName: string,
comparisonBranchName: string
) {
private renderHeaderTitle(ourBranch: string, theirBranch?: string) {
if (theirBranch !== undefined) {
return (
<span>
{`Resolve conflicts before merging `}
<strong>{theirBranch}</strong>
{` into `}
<strong>{ourBranch}</strong>
</span>
)
}
return (
<span>
{`Resolve conflicts before merging `}
<strong>{comparisonBranchName}</strong>
{` into `}
<strong>{currentBranchName}</strong>
{`Resolve conflicts before merging into `}
<strong>{ourBranch}</strong>
</span>
)
}
private editorButtonString(editorName: string | undefined) {
const defaultEditorString = 'editor'
return `Open in ${editorName || defaultEditorString}`
}
private openThisRepositoryInShell = () =>
this.props.openRepositoryInShell(this.props.repository)
@ -139,37 +175,68 @@ export class MergeConflictsDialog extends React.Component<
private renderConflictedFile(
path: string,
conflictStatus: ConflictFileStatus,
editorName: string | undefined,
onOpenEditorClick: () => void
): JSX.Element | null {
let content = null
if (conflictStatus.kind === 'text') {
const humanReadableConflicts = this.calculateConflicts(
conflictStatus.conflictMarkerCount
)
const message =
humanReadableConflicts === 1
? `1 conflict`
: `${humanReadableConflicts} conflicts`
return (
<li className="unmerged-file-status-conflicts">
<Octicon symbol={OcticonSymbol.fileCode} className="file-octicon" />
<div className="column-left">
<PathText path={path} availableWidth={200} />
<div className="file-conflicts-status">{message}</div>
if (conflictStatus.conflictMarkerCount === null) {
content = (
<div>
<PathText path={path} availableWidth={400} />
<div className="command-line-hint">
Use command line to resolve this file
</div>
</div>
<Button onClick={onOpenEditorClick}>
{this.editorButtonString(editorName)}
</Button>
</li>
)
} else {
const humanReadableConflicts = calculateConflicts(
conflictStatus.conflictMarkerCount
)
const message =
humanReadableConflicts === 1
? `1 conflict`
: `${humanReadableConflicts} conflicts`
const disabled = this.props.resolvedExternalEditor === null
const tooltip = editorButtonTooltip(this.props.resolvedExternalEditor)
content = (
<>
<div className="column-left">
<PathText path={path} availableWidth={200} />
<div className="file-conflicts-status">{message}</div>
</div>
<Button
onClick={onOpenEditorClick}
disabled={disabled}
tooltip={tooltip}
>
{editorButtonString(this.props.resolvedExternalEditor)}
</Button>
</>
)
}
} else if (conflictStatus.kind === 'binary') {
content = (
<div>
<PathText path={path} availableWidth={400} />
<div className="command-line-hint">
Use command line to resolve binary files
</div>
</div>
)
}
return null
return content !== null ? (
<li className="unmerged-file-status-conflicts">
<Octicon symbol={OcticonSymbol.fileCode} className="file-octicon" />
{content}
</li>
) : null
}
private renderUnmergedFile(
file: WorkingDirectoryFileChange,
editorName: string | undefined,
repositoryPath: string
file: WorkingDirectoryFileChange
): JSX.Element | null {
switch (file.status) {
case AppFileStatus.Resolved:
@ -181,12 +248,10 @@ export class MergeConflictsDialog extends React.Component<
)
}
return this.renderConflictedFile(
file.path,
file.conflictStatus,
editorName,
() =>
this.props.openFileInExternalEditor(join(repositoryPath, file.path))
return this.renderConflictedFile(file.path, file.conflictStatus, () =>
this.props.openFileInExternalEditor(
join(this.props.repository.path, file.path)
)
)
default:
return null
@ -194,25 +259,15 @@ export class MergeConflictsDialog extends React.Component<
}
private renderUnmergedFiles(
files: Array<WorkingDirectoryFileChange>,
editorName: string | undefined,
repositoryPath: string
files: ReadonlyArray<WorkingDirectoryFileChange>
) {
return (
<ul className="unmerged-file-statuses">
{files.map(f => this.renderUnmergedFile(f, editorName, repositoryPath))}
{files.map(f => this.renderUnmergedFile(f))}
</ul>
)
}
private getUnmergedFiles() {
return this.props.status.files.filter(
file =>
file.status === AppFileStatus.Conflicted ||
file.status === AppFileStatus.Resolved
)
}
private renderUnmergedFilesSummary(conflictedFilesCount: number) {
// localization, it burns :vampire:
const message =
@ -222,19 +277,49 @@ export class MergeConflictsDialog extends React.Component<
return <h3 className="summary">{message}</h3>
}
private renderAllResolved() {
return (
<div className="all-conflicts-resolved">
<div className="green-circle">
<Octicon symbol={OcticonSymbol.check} />
</div>
<div className="message">All conflicts resolved</div>
</div>
)
}
private renderContent(
unmergedFiles: ReadonlyArray<WorkingDirectoryFileChange>,
conflictedFilesCount: number
): JSX.Element {
if (unmergedFiles.length === 0) {
return this.renderAllResolved()
}
return (
<>
{this.renderUnmergedFilesSummary(conflictedFilesCount)}
{this.renderUnmergedFiles(unmergedFiles)}
{this.renderShellLink(this.openThisRepositoryInShell)}
</>
)
}
public render() {
const unmergedFiles = this.getUnmergedFiles()
const unmergedFiles = getUnmergedFiles(this.props.workingDirectory)
const conflictedFilesCount = unmergedFiles.filter(
f => f.status === AppFileStatus.Conflicted
).length
const headerTitle = this.renderHeaderTitle(
this.props.currentBranch,
this.props.ourBranch,
this.props.theirBranch
)
const tooltipString =
conflictedFilesCount > 0
? 'Resolve all changes before merging'
: undefined
return (
<Dialog
id="merge-conflicts-list"
@ -244,13 +329,7 @@ export class MergeConflictsDialog extends React.Component<
>
<DialogHeader title={headerTitle} dismissable={false} />
<DialogContent>
{this.renderUnmergedFilesSummary(conflictedFilesCount)}
{this.renderUnmergedFiles(
unmergedFiles,
this.props.externalEditorName,
this.props.repository.path
)}
{this.renderShellLink(this.openThisRepositoryInShell)}
{this.renderContent(unmergedFiles, conflictedFilesCount)}
</DialogContent>
<DialogFooter>
<ButtonGroup>

View file

@ -153,8 +153,8 @@ export class RepositoryListItem extends React.Component<
const showRepositoryLabel = __DARWIN__
? 'Show in Finder'
: __WIN32__
? 'Show in Explorer'
: 'Show in your File Manager'
? 'Show in Explorer'
: 'Show in your File Manager'
const items: ReadonlyArray<IMenuItem> = [
{

View file

@ -1,44 +0,0 @@
<html>
<head>
<style>
#error-page {
margin: 10px;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif;
}
.stack {
font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
</style>
</head>
<body>
<div id='error-page'>
<h1>GitHub Desktop failed to launch &#x1F4A5;</h1>
<p>
We encountered a catastrophic error that prevents GitHub Desktop from
launching successfully.
</p>
<p>
Unfortunately this isn't something that can be forwarded to the development
team to triage.
</p>
<p>
Would you mind opening an issue on the GitHub Desktop <a onclick='return onClick(event);' href='https://github.com/desktop/desktop/issues'>issue tracker</a>?
The details you need are included below:
</p>
<!--{{content}}-->
</div>
<script>
function onClick(e) {
e.preventDefault()
const { shell } = require('electron')
const url = 'https://github.com/desktop/desktop/issues/new'
shell.openExternal(url)
}
</script>
</body>
</html>

View file

@ -1,9 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset='UTF-8'>
<meta charset="UTF-8" />
</head>
<body>
<div id='desktop-app-container'></div>
<div id="desktop-app-container"></div>
</body>
</html>

View file

@ -4,14 +4,14 @@
height: 100%;
display: flex;
flex-direction: column;
width: 300px;
width: 350px;
& > .tab-bar {
border-top: var(--base-border);
}
.branches-list {
width: 300px;
width: 350px;
}
}
@ -228,7 +228,7 @@
}
.no-pull-requests {
width: 300px;
width: 350px;
display: flex;
flex-direction: column;

View file

@ -5,6 +5,7 @@
display: flex;
flex-direction: row;
flex: 1;
border-top: var(--base-border);
> .focus-container {
display: flex;

View file

@ -1,4 +1,6 @@
#successful-merge {
$banner-height: 30px;
height: $banner-height;
position: relative;
display: flex;
flex-wrap: nowrap;
@ -7,10 +9,7 @@
align-items: center;
justify-content: left;
padding-left: var(--spacing);
border-bottom: var(--base-border);
height: 0px;
transition: height 0.25s;
// Prevents the notification banner from decreasing its height
// when a large diff is rendered.
flex: none;
@ -27,10 +26,6 @@
flex: 1 1 auto;
}
&.active {
height: 30px;
}
.green-circle {
background-color: var(--color-new);
color: var(--background-color);
@ -46,6 +41,11 @@
flex-grow: 0;
}
.check-icon {
height: 12px;
width: 12px;
}
.close {
display: flex;
flex-shrink: 0;
@ -66,4 +66,27 @@
outline: 0;
}
}
// animations for entering and leaving
&.banner-enter {
height: 0px;
opacity: 0;
&.banner-enter-active {
height: $banner-height;
opacity: 1;
transition: height 300ms ease-in-out, opacity 200ms ease-in 300ms;
}
}
&.banner-leave {
height: $banner-height;
opacity: 1;
&.banner-leave-active {
height: 0px;
opacity: 0;
transition: height 225ms ease-in-out 175ms, opacity 175ms ease-in;
}
}
}

View file

@ -38,7 +38,7 @@ dialog#merge-conflicts-list {
}
li:last-of-type {
margin-bottom: calc(var(--spacing) * 5);
margin-bottom: calc(var(--spacing) * 2);
}
li.unmerged-file-status-resolved,
@ -66,6 +66,7 @@ dialog#merge-conflicts-list {
button:last-child,
.green-circle:last-child {
margin-left: auto;
margin-top: var(--spacing);
flex-shrink: 0;
flex: 0 1 auto;
}
@ -74,8 +75,26 @@ dialog#merge-conflicts-list {
.unmerged-file-status-resolved .file-conflicts-status {
color: $green;
}
.unmerged-file-status-conflicts .file-conflicts-status {
color: $orange;
.unmerged-file-status-conflicts {
.file-conflicts-status {
color: $orange;
}
.command-line-hint {
color: $gray;
}
}
}
.all-conflicts-resolved {
display: flex;
flex-flow: row nowrap;
padding: var(--spacing) 0 var(--spacing-double);
.message {
padding-left: var(--spacing);
padding-top: var(--spacing-third);
}
}

View file

@ -7,27 +7,33 @@ describe('formatCommitMessage', () => {
it('always adds trailing newline', async () => {
const repo = await setupEmptyRepository()
expect(await formatCommitMessage(repo, 'test', null)).to.equal('test\n')
expect(await formatCommitMessage(repo, 'test', 'test')).to.equal(
'test\n\ntest\n'
)
expect(
await formatCommitMessage(repo, { summary: 'test', description: null })
).to.equal('test\n')
expect(
await formatCommitMessage(repo, { summary: 'test', description: 'test' })
).to.equal('test\n\ntest\n')
})
it('omits description when null', async () => {
const repo = await setupEmptyRepository()
expect(await formatCommitMessage(repo, 'test', null)).to.equal('test\n')
expect(
await formatCommitMessage(repo, { summary: 'test', description: null })
).to.equal('test\n')
})
it('omits description when empty string', async () => {
const repo = await setupEmptyRepository()
expect(await formatCommitMessage(repo, 'test', '')).to.equal('test\n')
expect(
await formatCommitMessage(repo, { summary: 'test', description: '' })
).to.equal('test\n')
})
it('adds two newlines between summary and description', async () => {
const repo = await setupEmptyRepository()
expect(await formatCommitMessage(repo, 'foo', 'bar')).to.equal(
'foo\n\nbar\n'
)
expect(
await formatCommitMessage(repo, { summary: 'foo', description: 'bar' })
).to.equal('foo\n\nbar\n')
})
it('appends trailers to a summary-only message', async () => {
@ -36,7 +42,13 @@ describe('formatCommitMessage', () => {
{ token: 'Co-Authored-By', value: 'Markus Olsson <niik@github.com>' },
{ token: 'Signed-Off-By', value: 'nerdneha <nerdneha@github.com>' },
]
expect(await formatCommitMessage(repo, 'foo', null, trailers)).to.equal(
expect(
await formatCommitMessage(repo, {
summary: 'foo',
description: null,
trailers,
})
).to.equal(
'foo\n\n' +
'Co-Authored-By: Markus Olsson <niik@github.com>\n' +
'Signed-Off-By: nerdneha <nerdneha@github.com>\n'
@ -49,7 +61,13 @@ describe('formatCommitMessage', () => {
{ token: 'Co-Authored-By', value: 'Markus Olsson <niik@github.com>' },
{ token: 'Signed-Off-By', value: 'nerdneha <nerdneha@github.com>' },
]
expect(await formatCommitMessage(repo, 'foo', 'bar', trailers)).to.equal(
expect(
await formatCommitMessage(repo, {
summary: 'foo',
description: 'bar',
trailers,
})
).to.equal(
'foo\n\nbar\n\n' +
'Co-Authored-By: Markus Olsson <niik@github.com>\n' +
'Signed-Off-By: nerdneha <nerdneha@github.com>\n'
@ -64,12 +82,11 @@ describe('formatCommitMessage', () => {
{ token: 'Signed-Off-By', value: 'nerdneha <nerdneha@github.com>' },
]
expect(
await formatCommitMessage(
repo,
'foo',
'Co-Authored-By: Markus Olsson <niik@github.com>',
trailers
)
await formatCommitMessage(repo, {
summary: 'foo',
description: 'Co-Authored-By: Markus Olsson <niik@github.com>',
trailers,
})
).to.equal(
'foo\n\n' +
'Co-Authored-By: Markus Olsson <niik@github.com>\n' +
@ -85,13 +102,12 @@ describe('formatCommitMessage', () => {
]
expect(
await formatCommitMessage(
repo,
'foo',
await formatCommitMessage(repo, {
summary: 'foo',
// note the lack of space after :
'Co-Authored-By:Markus Olsson <niik@github.com>',
trailers
)
description: 'Co-Authored-By:Markus Olsson <niik@github.com>',
trailers,
})
).to.equal(
'foo\n\n' +
'Co-Authored-By: Markus Olsson <niik@github.com>\n' +

View file

@ -1,4 +1,3 @@
import { expect } from 'chai'
import { shell } from '../../helpers/test-app-shell'
import {
setupEmptyRepository,
@ -14,6 +13,7 @@ import {
} from '../../../src/models/tip'
import { GitStore } from '../../../src/lib/stores'
import { GitProcess } from 'dugite'
import { getBranchesPointedAt, createBranch } from '../../../src/lib/git'
describe('git/branch', () => {
describe('tip', () => {
@ -24,9 +24,9 @@ describe('git/branch', () => {
await store.loadStatus()
const tip = store.tip
expect(tip.kind).to.equal(TipState.Unborn)
expect(tip.kind).toEqual(TipState.Unborn)
const unborn = tip as IUnbornRepository
expect(unborn.ref).to.equal('master')
expect(unborn.ref).toEqual('master')
})
it('returns correct ref if checkout occurs', async () => {
@ -38,9 +38,9 @@ describe('git/branch', () => {
await store.loadStatus()
const tip = store.tip
expect(tip.kind).to.equal(TipState.Unborn)
expect(tip.kind).toEqual(TipState.Unborn)
const unborn = tip as IUnbornRepository
expect(unborn.ref).to.equal('not-master')
expect(unborn.ref).toEqual('not-master')
})
it('returns detached for arbitrary checkout', async () => {
@ -51,9 +51,9 @@ describe('git/branch', () => {
await store.loadStatus()
const tip = store.tip
expect(tip.kind).to.equal(TipState.Detached)
expect(tip.kind).toEqual(TipState.Detached)
const detached = tip as IDetachedHead
expect(detached.currentSha).to.equal(
expect(detached.currentSha).toEqual(
'2acb028231d408aaa865f9538b1c89de5a2b9da8'
)
})
@ -66,10 +66,10 @@ describe('git/branch', () => {
await store.loadStatus()
const tip = store.tip
expect(tip.kind).to.equal(TipState.Valid)
expect(tip.kind).toEqual(TipState.Valid)
const onBranch = tip as IValidBranch
expect(onBranch.branch.name).to.equal('commit-with-long-description')
expect(onBranch.branch.tip.sha).to.equal(
expect(onBranch.branch.name).toEqual('commit-with-long-description')
expect(onBranch.branch.tip.sha).toEqual(
'dfa96676b65e1c0ed43ca25492252a5e384c8efd'
)
})
@ -82,9 +82,9 @@ describe('git/branch', () => {
await store.loadStatus()
const tip = store.tip
expect(tip.kind).to.equal(TipState.Valid)
expect(tip.kind).toEqual(TipState.Valid)
const valid = tip as IValidBranch
expect(valid.branch.remote).to.equal('bassoon')
expect(valid.branch.remote).toEqual('bassoon')
})
})
@ -97,12 +97,52 @@ describe('git/branch', () => {
await store.loadStatus()
const tip = store.tip
expect(tip.kind).to.equal(TipState.Valid)
expect(tip.kind).toEqual(TipState.Valid)
const valid = tip as IValidBranch
expect(valid.branch.remote).to.equal('bassoon')
expect(valid.branch.upstream).to.equal('bassoon/master')
expect(valid.branch.upstreamWithoutRemote).to.equal('master')
expect(valid.branch.remote).toEqual('bassoon')
expect(valid.branch.upstream).toEqual('bassoon/master')
expect(valid.branch.upstreamWithoutRemote).toEqual('master')
})
})
describe('getBranchesPointedAt', () => {
let repository: Repository
describe('in a local repo', () => {
beforeEach(async () => {
const path = await setupFixtureRepository('test-repo')
repository = new Repository(path, -1, null, false)
})
it('finds one branch name', async () => {
const branches = await getBranchesPointedAt(repository, 'HEAD')
expect(branches).toHaveLength(1)
expect(branches![0]).toEqual('master')
})
it('finds no branch names', async () => {
const branches = await getBranchesPointedAt(repository, 'HEAD^')
expect(branches).toHaveLength(0)
})
it('returns null on a malformed committish', async () => {
const branches = await getBranchesPointedAt(repository, 'MERGE_HEAD')
expect(branches).toBeNull()
})
})
describe('in a repo with identical branches', () => {
beforeEach(async () => {
const path = await setupFixtureRepository('repo-with-multiple-remotes')
repository = new Repository(path, -1, null, false)
await createBranch(repository, 'other-branch')
})
it('finds multiple branch names', async () => {
const branches = await getBranchesPointedAt(repository, 'HEAD')
expect(branches).toHaveLength(2)
expect(branches).toContain('other-branch')
expect(branches).toContain('master')
})
})
})
})

View file

@ -43,7 +43,6 @@ describe('git/checkout', () => {
parentSHAs: [],
trailers: [],
coAuthors: [],
isWebFlowCommitter: () => false,
},
remote: null,
}

View file

@ -163,33 +163,25 @@ describe('git/status', () => {
expect(files[1].path).toBe('docs/OVERVIEW.md')
})
it(
'Handles at least 10k untracked files without failing',
async () => {
const numFiles = 10000
const basePath = repository!.path
it('Handles at least 10k untracked files without failing', async () => {
const numFiles = 10000
const basePath = repository!.path
await mkdir(basePath)
await mkdir(basePath)
// create a lot of files
const promises = []
for (let i = 0; i < numFiles; i++) {
promises.push(
FSE.writeFile(
path.join(basePath, `test-file-${i}`),
'Hey there\n'
)
)
}
await Promise.all(promises)
// create a lot of files
const promises = []
for (let i = 0; i < numFiles; i++) {
promises.push(
FSE.writeFile(path.join(basePath, `test-file-${i}`), 'Hey there\n')
)
}
await Promise.all(promises)
const status = await getStatusOrThrow(repository!)
const files = status.workingDirectory.files
expect(files).toHaveLength(numFiles)
},
// needs a little extra time on CI
25000
)
const status = await getStatusOrThrow(repository!)
const files = status.workingDirectory.files
expect(files).toHaveLength(numFiles)
}, 25000) // needs a little extra time on CI
it('returns null for directory without a .git directory', async () => {
repository = setupEmptyDirectory()

View file

@ -4,7 +4,7 @@ platform:
- x64
environment:
nodejs_version: '8.11'
nodejs_version: '8.12'
cache:
- .eslintcache

View file

@ -1,3 +1,6 @@
variables:
PYTHON: 'python2.7'
jobs:
- job: Windows
pool:
@ -5,7 +8,7 @@ jobs:
steps:
- task: NodeTool@0
inputs:
versionSpec: '8.11.1'
versionSpec: '8.12.0'
- task: geeklearningio.gl-vsts-tasks-yarn.yarn-installer-task.YarnInstaller@2
inputs:
versionSpec: '1.5.1'
@ -28,7 +31,7 @@ jobs:
sudo apt-get install -y --no-install-recommends libsecret-1-dev xvfb fakeroot dpkg rpm xz-utils xorriso zsync libxss1 libgconf2-4 libgtk-3-0
- task: NodeTool@0
inputs:
versionSpec: '8.11.1'
versionSpec: '8.12.0'
- task: geeklearningio.gl-vsts-tasks-yarn.yarn-installer-task.YarnInstaller@2
inputs:
versionSpec: '1.5.1'
@ -50,7 +53,7 @@ jobs:
steps:
- task: NodeTool@0
inputs:
versionSpec: '8.11.1'
versionSpec: '8.12.0'
- script: |
yarn install --force
name: Install
@ -70,7 +73,7 @@ jobs:
sudo apt-get install -y --no-install-recommends libsecret-1-dev
- task: NodeTool@0
inputs:
versionSpec: '8.11.1'
versionSpec: '8.12.0'
- task: geeklearningio.gl-vsts-tasks-yarn.yarn-installer-task.YarnInstaller@2
inputs:
versionSpec: '1.5.1'

View file

@ -1,5 +1,37 @@
{
"releases": {
"1.5.1-beta0": [
],
"1.5.0": [
"[New] Clone, create, or add repositories right from the repository dropdown - #5878",
"[New] Drag-and-drop to add local repositories from macOS tray icon - #5048",
"[Added] Resolve merge conflicts through a guided flow - #5400",
"[Added] Allow merging branches directly from branch dropdown - #5929. Thanks @bruncun!",
"[Added] Commit file list now has \"Copy File Path\" context menu action - #2944. Thanks @Amabel!",
"[Added] Keyboard shortcut for \"Rename Branch\" menu item - #5964. Thanks @agisilaos!",
"[Added] Notify users when a merge is successfully completed - #5851",
"[Fixed] \"Compare on GitHub\" menu item enabled when no repository is selected - #6078",
"[Fixed] Diff viewer blocks keyboard navigation using reverse tab order - #2794",
"[Fixed] Launching Desktop from browser always asks to clone repository - #5913",
"[Fixed] Publish dialog displayed on push when repository is already published - #5936",
"[Improved] \"Publish Repository\" dialog handles emoji characters - #5980. Thanks @WaleedAshraf!",
"[Improved] Avoid repository checks when no path is specified in \"Create Repository\" dialog - #5828. Thanks @JakeHL!",
"[Improved] Clarify the direction of merging branches - #5930. Thanks @JQuinnie!",
"[Improved] Default commit summary more explanatory and consistent with GitHub.com - #6017. Thanks @Daniel-McCarthy!",
"[Improved] Display a more informative message on merge dialog when branch is up to date - #5890",
"[Improved] Getting a repository's status only blocks other operations when absolutely necessary - #5952",
"[Improved] Display current branch in header of merge dialog - #6027",
"[Improved] Sanitize repository name before publishing to GitHub - #3090. Thanks @Daniel-McCarthy!",
"[Improved] Show the branch name in \"Update From Default Branch\" menu item - #3018. Thanks @a-golovanov!",
"[Improved] Update license and .gitignore templates for initializing a new repository - #6024. Thanks @say25!"
],
"1.5.0-beta5": [
],
"1.5.0-beta4": [
"[Fixed] \"Compare on GitHub\" menu item enabled when no repository is selected - #6078",
"[Fixed] Diff viewer blocks keyboard navigation using reverse tab order - #2794",
"[Improved] \"Publish Repository\" dialog handles emoji characters - #5980. Thanks @WaleedAshraf!"
],
"1.5.0-beta3": [
],
"1.5.0-beta2": [

View file

@ -2,7 +2,7 @@
You will need to install these tools on your machine:
- Node.js v8.11.4
- Node.js v8.12.0
- Python 2.7
- Xcode and Xcode Command Line Tools (Xcode -> Preferences -> Downloads)
@ -14,11 +14,11 @@ Let's see if you have the right version of `node` installed. Open a terminal and
$ node -v
```
If you see an error about being unable to find `node`, that probably means you don't have any Node tools installed. You can install Node LTS (the version we need) from the [Node.js website](https://nodejs.org/en/download/) and restart your shell.
If you see an error about being unable to find `node`, that probably means you don't have any Node tools installed. You can install Node 8 from the [Node.js website](https://nodejs.org/download/release/v8.12.0/) and restart your shell.
If you see the output `v8.11.x` (where `x` is any number), you're good to go.
If you see the output `v8.12.0`, you're good to go.
If you see the output `v10.x.y` you're ahead of what we currently support. We have an outstanding issue building GitHub Desktop with Node 10, and hopefully can resolve this soon. If you don't care about the version you are running, you can install the version from the [Node.js website](https://nodejs.org/en/download/) over the top of your current install.
If you see the output `v10.x.y` you're ahead of what we currently support. See [#5876](https://github.com/desktop/desktop/issues/5876) for details about building GitHub Desktop with Node 10, which we can hopefully resolve soon. If you don't care about the version you are running, you can install the version from the [Node.js website](https://nodejs.org/download/release/v8.12.0/) over the top of your current install.
### I need to use different versions of Node.js in different projects!
@ -46,7 +46,7 @@ $ nvm use
$ node -v
```
If you see `v8.11.4`, you're good to go.
If you see `v8.12.0`, you're good to go.
#### Configuring `asdf-nodejs`

View file

@ -10,7 +10,7 @@ See [mac-deps-setup.md](./setup-macos.md).
### Windows
- [Node.js v8.11.4](https://nodejs.org/dist/v8.11.4/)
- [Node.js v8.12.0](https://nodejs.org/dist/v8.12.0/)
- *Make sure you allow the Node.js installer to add node to the PATH.*
- [Python 2.7](https://www.python.org/downloads/windows/)
- *Let Python install into the default suggested path (`c:\Python27`), otherwise you'll have
@ -121,7 +121,7 @@ versions look similar to the below output:
```shellsession
$ node -v
v8.11.4
v8.12.0
$ yarn -v
1.9.4

View file

@ -92,6 +92,7 @@ time:
| | Label name | Description |
| ------------------------------- | ------------------ | ----------- |
| [:mag_right:][ready-for-review] | `ready-for-review` | Pull Requests that are ready to be reviewed by the maintainers |
| [:mag_right:][time-sensitive] | `time-sensitive` | Pull Requests that require review in a more timely manner |
[bug]: https://github.com/desktop/desktop/labels/bug
@ -116,6 +117,7 @@ time:
[ready-for-review]: https://github.com/desktop/desktop/labels/ready-for-review
[tech-debt]: https://github.com/desktop/desktop/labels/tech-debt
[themes]: https://github.com/desktop/desktop/labels/themes
[time-sensitive]: https://github.com/desktop/desktop/labels/time-sensitive
[user-research]: https://github.com/desktop/desktop/labels/user-research
[website]: https://github.com/desktop/desktop/labels/website
[windows]: https://github.com/desktop/desktop/labels/windows

View file

@ -8,7 +8,7 @@ For external contributors, we have bundled a developer OAuth application
with the Desktop application so that you can complete the sign in flow locally
without needing to configure your own application.
These are listed in [app/webpack.common.js](https://github.com/desktop/desktop/blob/c286d0d513d82b97e1a9c60d44c23020f2ba34d7/app/webpack.common.js#L9-L10).
These are listed in [app/app-info.ts](https://github.com/desktop/desktop/blob/85cf9dbae5055cc4f0de9fb4f7046cd32607e877/app/app-info.ts#L9-L10).
**DO NOT TRUST THIS CLIENT ID AND SECRET! THIS IS ONLY FOR TESTING PURPOSES!!**

View file

@ -8,7 +8,7 @@ We introduced syntax highlighted diffs in [#3101](https://github.com/desktop/des
We currently support syntax highlighting for the following languages.
JavaScript, JSON, TypeScript, Coffeescript, HTML, CSS, SCSS, LESS, VUE, Markdown, Yaml, XML, Objective-C, Scala, C#, Java, C, C++, Kotlin, Ocaml, F#, sh/bash, Swift, SQL, CYPHER, Go, Perl, PHP, Python, Ruby, Clojure, Rust, Elixir, Haxe, R, JavaServer Pages, PowerShell
JavaScript, JSON, TypeScript, Coffeescript, HTML, CSS, SCSS, LESS, VUE, Markdown, Yaml, XML, Objective-C, Scala, C#, Java, C, C++, Kotlin, Ocaml, F#, sh/bash, Swift, SQL, CYPHER, Go, Perl, PHP, Python, Ruby, Clojure, Rust, Elixir, Haxe, R, JavaServer Pages, PowerShell, Docker
This list was never meant to be exhaustive, we expect to add more languages going forward but this seemed like a good first step.

View file

@ -23,7 +23,7 @@ module.exports = {
// ignore index files
'!**/index.ts',
],
coverageReporters: ['text-summary', 'json'],
coverageReporters: ['text-summary', 'json', 'html'],
globals: {
'ts-jest': {
useBabelrc: true,

View file

@ -13,7 +13,7 @@
"test": "yarn test:unit:cov --runInBand && yarn test:script && yarn test:integration",
"test:setup": "ts-node -P script/tsconfig.json script/test-setup.ts",
"test:review": "ts-node -P script/tsconfig.json script/test-review.ts",
"test:report": "codecov -f coverage/*.json",
"test:report": "codecov --disable=gcov -f coverage/coverage-final.json",
"postinstall": "ts-node -P script/tsconfig.json script/post-install.ts",
"start": "cross-env NODE_ENV=development ts-node -P script/tsconfig.json script/start.ts",
"start:prod": "cross-env NODE_ENV=production ts-node -P script/tsconfig.json script/start.ts",
@ -93,7 +93,7 @@
"node-sass": "^4.7.2",
"octicons": "^7.0.1",
"parallel-webpack": "^2.3.0",
"prettier": "^1.14.2",
"prettier": "1.15.2",
"request": "^2.72.0",
"rimraf": "^2.5.2",
"sass-loader": "^7.0.1",

View file

@ -12,7 +12,7 @@ const prettier = process.platform === 'win32' ? 'prettier.cmd' : 'prettier'
const prettierPath = Path.join(root, 'node_modules', '.bin', prettier)
const args = [
'**/*.{scss,y{,a}ml}',
'**/*.{scss,y{,a}ml,html}',
'app/**/*.{ts,tsx}',
'script/**/*.ts',
'--list-different',

View file

@ -8392,10 +8392,10 @@ prettier-linter-helpers@^1.0.0:
dependencies:
fast-diff "^1.1.2"
prettier@^1.14.2:
version "1.14.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.14.2.tgz#0ac1c6e1a90baa22a62925f41963c841983282f9"
integrity sha512-McHPg0n1pIke+A/4VcaS2en+pTNjy4xF+Uuq86u/5dyDO59/TtFZtQ708QIRkEZ3qwKz3GVkVa6mpxK/CpB8Rg==
prettier@1.15.2:
version "1.15.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.15.2.tgz#d31abe22afa4351efa14c7f8b94b58bb7452205e"
integrity sha512-YgPLFFA0CdKL4Eg2IHtUSjzj/BWgszDHiNQAe0VAIBse34148whfdzLagRL+QiKS+YfK5ftB6X4v/MBw8yCoug==
pretty-bytes@^1.0.2:
version "1.0.4"