Merge remote-tracking branch 'upstream/development' into windows-arm-support

This commit is contained in:
Dennis Ameling 2021-02-11 14:40:07 +01:00
commit a452402fd1
249 changed files with 6852 additions and 7407 deletions

View file

@ -14,7 +14,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [12.14.1]
node: [14.x]
os: [macos-10.15, windows-2019]
arch: [x64, arm64]
include:
@ -92,6 +92,7 @@ jobs:
run: yarn test:script:cov
- name: Run integration tests
if: matrix.arch == 'x64'
timeout-minutes: 5
run: yarn test:integration
- name: Publish production app
run: yarn run publish

View file

@ -1 +1 @@
12.14.1
14.15.1

2
.nvmrc
View file

@ -1 +1 @@
v10
v14

View file

@ -1,2 +1,2 @@
python 2.7.16
nodejs 12.14.1
nodejs 14.15.1

View file

@ -1,8 +1,7 @@
{
"recommendations": [
"ms-vscode.vscode-typescript-tslint-plugin",
"msjsdiag.debugger-for-chrome",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
"dbaeumer.vscode-eslint",
"stkb.rewrap"
]
}

View file

@ -5,7 +5,8 @@
"**/dist": true,
"**/node_modules": true,
"**/out": true,
"app/test/fixtures": true
"app/test/fixtures": true,
"vendor": true
},
"files.exclude": {
"**/.git": true,
@ -19,6 +20,7 @@
".awcache": true,
".eslintcache": true
},
"files.insertFinalNewline": true,
"editor.tabSize": 2,
"prettier.semi": false,
"prettier.singleQuote": true,

View file

@ -28,6 +28,8 @@ beta channel to get access to early builds of Desktop:
- [macOS](https://central.github.com/deployments/desktop/desktop/latest/darwin?env=beta)
- [Windows](https://central.github.com/deployments/desktop/desktop/latest/win32?env=beta)
The release notes for the latest beta versions are available [here](https://desktop.github.com/release-notes/?env=beta).
### Community Releases
@ -36,7 +38,7 @@ install GitHub Desktop:
- Windows users can install using [Chocolatey](https://chocolatey.org/) package manager:
`c:\> choco install github-desktop`
- macOS users can install using [Homebrew](https://brew.sh/) package manager:
`$ brew cask install github`
`$ brew install --cask github`
Installers for various Linux distributions can be found on the
[`shiftkey/desktop`](https://github.com/shiftkey/desktop) fork.

View file

@ -1,3 +1,3 @@
runtime = electron
disturl = https://atom.io/download/electron
target = 9.3.1
target = 11.1.1

View file

@ -16,5 +16,7 @@ export function getVersion() {
}
export function getBundleID() {
return appPackage.bundleID
return process.env.NODE_ENV === 'development'
? `${appPackage.bundleID}Dev`
: appPackage.bundleID
}

View file

@ -3,7 +3,7 @@
"productName": "GitHub Desktop",
"bundleID": "com.github.GitHubClient",
"companyName": "GitHub, Inc.",
"version": "2.6.0",
"version": "2.6.3-beta3",
"main": "./main.js",
"repository": {
"type": "git",
@ -27,7 +27,7 @@
"deep-equal": "^1.0.1",
"dexie": "^2.0.0",
"double-ended-queue": "^2.1.0-0",
"dugite": "1.92.0",
"dugite": "^1.97.0",
"electron-window-state": "^5.0.3",
"event-kit": "^2.0.0",
"file-metadata": "^1.0.0",
@ -35,7 +35,7 @@
"file-url": "^2.0.2",
"focus-trap-react": "^8.1.0",
"fs-admin": "^0.15.0",
"fs-extra": "^7.0.1",
"fs-extra": "^9.0.1",
"fuzzaldrin-plus": "^0.6.0",
"keytar": "^7.2.0",
"mem": "^4.3.0",
@ -46,7 +46,6 @@
"p-limit": "^2.2.0",
"primer-support": "^4.0.0",
"prop-types": "^15.7.2",
"queue": "^5.0.0",
"quick-lru": "^3.0.0",
"react": "^16.8.4",
"react-css-transition-replace": "^3.0.3",

View file

@ -76,7 +76,7 @@ const DotComOAuthScopes = ['repo', 'user', 'workflow']
/**
* The OAuth scopes we want to request from GitHub
* Enterprise Server.
* Enterprise.
*/
const EnterpriseOAuthScopes = ['repo', 'user']
@ -103,7 +103,35 @@ export interface IAPIRepository {
readonly pushed_at: string
readonly has_issues: boolean
readonly archived: boolean
readonly parent?: IAPIRepository
}
/** Information needed to clone a repository. */
export interface IAPIRepositoryCloneInfo {
/** Canonical clone URL of the repository. */
readonly url: string
/**
* Default branch of the repository, if any. This is usually either retrieved
* from the API for GitHub repositories, or undefined for other repositories.
*/
readonly defaultBranch?: string
}
export interface IAPIFullRepository extends IAPIRepository {
/**
* The parent repository of a fork.
*
* HACK: BEWARE: This is defined as `parent: IAPIRepository | undefined`
* rather than `parent?: ...` even though the parent property is actually
* optional in the API response. So we're lying a bit to the type system
* here saying that this will be present but the only time the difference
* between omission and explicit undefined matters is when using constructs
* like `x in y` or `y.hasOwnProperty('x')` which we do very rarely.
*
* Without at least one non-optional type in this interface TypeScript will
* happily let us pass an IAPIRepository in place of an IAPIFullRepository.
*/
readonly parent: IAPIRepository | undefined
/**
* The high-level permissions that the currently authenticated
@ -559,14 +587,14 @@ export class API {
public async fetchRepository(
owner: string,
name: string
): Promise<IAPIRepository | null> {
): Promise<IAPIFullRepository | null> {
try {
const response = await this.request('GET', `repos/${owner}/${name}`)
if (response.status === HttpStatusCode.NotFound) {
log.warn(`fetchRepository: '${owner}/${name}' returned a 404`)
return null
}
return await parsedResponse<IAPIRepository>(response)
return await parsedResponse<IAPIFullRepository>(response)
} catch (e) {
log.warn(`fetchRepository: an error occurred for '${owner}/${name}'`, e)
return null
@ -574,8 +602,11 @@ export class API {
}
/**
* Fetch the canonical clone URL for a repository, respecting the protocol
* preference if provided.
* Fetch info needed to clone a repository. That includes:
* - The canonical clone URL for a repository, respecting the protocol
* preference if provided.
* - The default branch of the repository, in case the repository is empty.
* Only available for GitHub repositories.
*
* Returns null if the request returned a 404 (NotFound). NotFound doesn't
* necessarily mean that the repository doesn't exist, it could exist and
@ -590,11 +621,11 @@ export class API {
* @param name The repository name (node in https://github.com/nodejs/node)
* @param protocol The preferred Git protocol (https or ssh)
*/
public async fetchRepositoryCloneUrl(
public async fetchRepositoryCloneInfo(
owner: string,
name: string,
protocol: GitProtocol | undefined
): Promise<string | null> {
): Promise<IAPIRepositoryCloneInfo | null> {
const response = await this.request('GET', `repos/${owner}/${name}`)
if (response.status === HttpStatusCode.NotFound) {
@ -602,7 +633,10 @@ export class API {
}
const repo = await parsedResponse<IAPIRepository>(response)
return protocol === 'ssh' ? repo.ssh_url : repo.clone_url
return {
url: protocol === 'ssh' ? repo.ssh_url : repo.clone_url,
defaultBranch: repo.default_branch,
}
}
/** Fetch all repos a user has access to. */
@ -615,7 +649,7 @@ export class API {
// Ordinarily you'd be correct but turns out there's super
// rare circumstances where a user has been deleted but the
// repository hasn't. Such cases are usually addressed swiftly
// but in some cases like GitHub Enterprise Server instances
// but in some cases like GitHub Enterprise instances
// they can linger for longer than we'd like so we'll make
// sure to exclude any such dangling repository, chances are
// they won't be cloneable anyway.
@ -667,7 +701,7 @@ export class API {
name: string,
description: string,
private_: boolean
): Promise<IAPIRepository> {
): Promise<IAPIFullRepository> {
try {
const apiPath = org ? `orgs/${org.login}/repos` : 'user/repos'
const response = await this.request('POST', apiPath, {
@ -676,7 +710,7 @@ export class API {
private: private_,
})
return await parsedResponse<IAPIRepository>(response)
return await parsedResponse<IAPIFullRepository>(response)
} catch (e) {
if (e instanceof APIError) {
if (org !== null) {
@ -698,11 +732,11 @@ export class API {
public async forkRepository(
owner: string,
name: string
): Promise<IAPIRepository> {
): Promise<IAPIFullRepository> {
try {
const apiPath = `/repos/${owner}/${name}/forks`
const response = await this.request('POST', apiPath)
return await parsedResponse<IAPIRepository>(response)
return await parsedResponse<IAPIFullRepository>(response)
} catch (e) {
log.error(
`forkRepository: failed to fork ${owner}/${name} at endpoint: ${this.endpoint}`,
@ -1294,7 +1328,7 @@ export function getHTMLURL(endpoint: string): string {
// In the case of GitHub.com, the HTML site lives on the parent domain.
// E.g., https://api.github.com -> https://github.com
//
// Whereas with Enterprise Server, it lives on the same domain but without the
// Whereas with Enterprise, it lives on the same domain but without the
// API path:
// E.g., https://github.mycompany.com/api/v3 -> https://github.mycompany.com
//

View file

@ -26,9 +26,7 @@ import { Popup } from '../models/popup'
import { SignInState } from './stores/sign-in-store'
import { WindowState } from './window-state'
import { ExternalEditor } from './editors'
import { Shell } from './shells'
import { ComparisonCache } from './comparison-cache'
import { ApplicationTheme } from '../ui/lib/application-theme'
import { IAccountRepositories } from './stores/api-repositories-store'
@ -38,7 +36,7 @@ import { GitRebaseProgress } from '../models/rebase'
import { RebaseFlowStep } from '../models/rebase-flow-step'
import { IStashEntry } from '../models/stash-entry'
import { TutorialStep } from '../models/tutorial-step'
import { UncommittedChangesStrategyKind } from '../models/uncommitted-changes-strategy'
import { UncommittedChangesStrategy } from '../models/uncommitted-changes-strategy'
export enum SelectionType {
Repository,
@ -175,10 +173,10 @@ export interface IAppState {
readonly askForConfirmationOnForcePush: boolean
/** How the app should handle uncommitted changes when switching branches */
readonly uncommittedChangesStrategyKind: UncommittedChangesStrategyKind
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
/** The external editor to use when opening repositories */
readonly selectedExternalEditor: ExternalEditor | null
readonly selectedExternalEditor: string | null
/** The current setting for whether the user has disable usage reports */
readonly optOutOfUsageTracking: boolean
@ -190,7 +188,7 @@ export interface IAppState {
* based on the search order in `app/src/lib/editors/{platform}.ts`
* - If no editors found, this will remain `null`
*/
readonly resolvedExternalEditor: ExternalEditor | null
readonly resolvedExternalEditor: string | null
/** What type of visual diff mode we should use to compare images */
readonly imageDiffType: ImageDiffType
@ -246,6 +244,11 @@ export interface IAppState {
* for more information
*/
readonly repositoryIndicatorsEnabled: boolean
/**
* Whether or not the app should use spell check on commit summary and description
*/
readonly commitSpellcheckEnabled: boolean
}
export enum FoldoutType {
@ -654,9 +657,6 @@ export interface ICompareBranch {
}
export interface ICompareState {
/** The current state of the NBBD banner */
readonly divergingBranchBannerState: IDivergingBranchBannerState
/** The current state of the compare form, based on user input */
readonly formState: IDisplayHistory | ICompareBranch
@ -675,8 +675,11 @@ export interface ICompareState {
/** The SHAs of commits to render in the compare list */
readonly commitSHAs: ReadonlyArray<string>
/** A list of all branches (remote and local) currently in the repository. */
readonly allBranches: ReadonlyArray<Branch>
/**
* A list of branches (remote and local) except the current branch, and
* Desktop fork remote branches (see `Branch.isDesktopForkRemoteBranch`)
**/
readonly branches: ReadonlyArray<Branch>
/**
* A list of zero to a few (at time of writing 5 but check loadRecentBranches
@ -697,32 +700,6 @@ export interface ICompareState {
* GitHub.com users are able to change their default branch in the web UI.
*/
readonly defaultBranch: Branch | null
/**
* A local cache of ahead/behind computations to compare other refs to the current branch
*/
readonly aheadBehindCache: ComparisonCache
/**
* The best candidate branch to compare the current branch to.
* Also includes the ahead/behind info for the inferred branch
* relative to the current branch.
*/
readonly inferredComparisonBranch: {
branch: Branch | null
aheadBehind: IAheadBehind | null
}
}
export interface IDivergingBranchBannerState {
/** Show the diverging notification banner */
readonly isPromptVisible: boolean
/** Has the user dismissed the notification banner? */
readonly isPromptDismissed: boolean
/** Show the diverging notification nudge on the tab */
readonly isNudgeVisible: boolean
}
export interface ICompareFormUpdate {

View file

@ -1,29 +0,0 @@
import { IAheadBehind } from '../models/branch'
import { revSymmetricDifference } from '../lib/git'
export class ComparisonCache {
private backingStore = new Map<string, IAheadBehind>()
public set(from: string, to: string, value: IAheadBehind) {
const key = revSymmetricDifference(from, to)
this.backingStore.set(key, value)
}
public get(from: string, to: string) {
const key = revSymmetricDifference(from, to)
return this.backingStore.get(key) || null
}
public has(from: string, to: string) {
const key = revSymmetricDifference(from, to)
return this.backingStore.has(key)
}
public get size() {
return this.backingStore.size
}
public clear() {
this.backingStore.clear()
}
}

View file

@ -1,7 +1,6 @@
import Dexie from 'dexie'
import { BaseDatabase } from './base-database'
import { GitHubRepository } from '../../models/github-repository'
import { fatalError, forceUnwrap } from '../fatal-error'
export interface IPullRequestRef {
/**
@ -134,11 +133,6 @@ export class PullRequestDatabase extends BaseDatabase {
* if it exists.
*/
public async deleteAllPullRequestsInRepository(repository: GitHubRepository) {
const dbId = forceUnwrap(
"Can't delete PRs for repository, no dbId",
repository.dbID
)
await this.transaction(
'rw',
this.pullRequests,
@ -147,7 +141,7 @@ export class PullRequestDatabase extends BaseDatabase {
await this.clearLastUpdated(repository)
await this.pullRequests
.where('[base.repoId+number]')
.between([dbId], [dbId + 1])
.between([repository.dbID], [repository.dbID + 1])
.delete()
}
)
@ -180,10 +174,6 @@ export class PullRequestDatabase extends BaseDatabase {
* yet been inserted into the database (i.e the dbID field is null).
*/
public getAllPullRequestsInRepository(repository: GitHubRepository) {
if (repository.dbID === null) {
return fatalError("Can't retrieve PRs for repository, no dbId")
}
return this.pullRequests
.where('[base.repoId+number]')
.between([repository.dbID], [repository.dbID + 1])
@ -194,10 +184,6 @@ export class PullRequestDatabase extends BaseDatabase {
* Get a single pull requests for a particular repository
*/
public getPullRequest(repository: GitHubRepository, prNumber: number) {
if (repository.dbID === null) {
return fatalError("Can't retrieve PRs for repository with a null dbID")
}
return this.pullRequests.get([repository.dbID, prNumber])
}
@ -212,10 +198,6 @@ export class PullRequestDatabase extends BaseDatabase {
* table.
*/
public async getLastUpdated(repository: GitHubRepository) {
if (repository.dbID === null) {
return fatalError("Can't retrieve PRs for repository with a null dbID")
}
const row = await this.pullRequestsLastUpdated.get(repository.dbID)
return row ? new Date(row.lastUpdated) : null
@ -226,12 +208,6 @@ export class PullRequestDatabase extends BaseDatabase {
* a given repository.
*/
public async clearLastUpdated(repository: GitHubRepository) {
if (repository.dbID === null) {
throw new Error(
"Can't clear last updated PR for repository with a null dbID"
)
}
await this.pullRequestsLastUpdated.delete(repository.dbID)
}
@ -246,10 +222,6 @@ export class PullRequestDatabase extends BaseDatabase {
* table.
*/
public async setLastUpdated(repository: GitHubRepository, lastUpdated: Date) {
if (repository.dbID === null) {
throw new Error("Can't set last updated for PR with a null dbID")
}
await this.pullRequestsLastUpdated.put({
repoId: repository.dbID,
lastUpdated: lastUpdated.getTime(),
@ -271,9 +243,5 @@ export function getPullRequestKey(
repository: GitHubRepository,
prNumber: number
) {
const dbId = forceUnwrap(
`Can get key for PR, repository not inserted in database.`,
repository.dbID
)
return [dbId, prNumber] as PullRequestKey
return [repository.dbID, prNumber] as PullRequestKey
}

View file

@ -3,13 +3,13 @@ import { BaseDatabase } from './base-database'
import { WorkflowPreferences } from '../../models/workflow-preferences'
export interface IDatabaseOwner {
readonly id?: number | null
readonly id?: number
readonly login: string
readonly endpoint: string
}
export interface IDatabaseGitHubRepository {
readonly id?: number | null
readonly id?: number
readonly ownerID: number
readonly name: string
readonly private: boolean | null
@ -41,13 +41,13 @@ export interface IDatabaseProtectedBranch {
}
export interface IDatabaseRepository {
readonly id?: number | null
readonly id?: number
readonly gitHubRepositoryID: number | null
readonly path: string
readonly missing: boolean
/** The last time the stash entries were checked for the repository */
readonly lastStashCheckDate: number | null
readonly lastStashCheckDate?: number | null
readonly workflowPreferences?: WorkflowPreferences
@ -117,6 +117,12 @@ export class RepositoriesDatabase extends BaseDatabase {
this.conditionalVersion(6, {
protectedBranches: '[repoId+name], repoId',
})
this.conditionalVersion(7, {
gitHubRepositories: '++id, &[ownerID+name]',
})
this.conditionalVersion(8, {}, ensureNoUndefinedParentID)
}
}
@ -142,3 +148,12 @@ function removeDuplicateGitHubRepositories(transaction: Dexie.Transaction) {
}
})
}
async function ensureNoUndefinedParentID(tx: Dexie.Transaction) {
return tx
.table<IDatabaseGitHubRepository, number>('gitHubRepositories')
.toCollection()
.filter(ghRepo => ghRepo.parentID === undefined)
.modify({ parentID: null })
.then(modified => log.info(`ensureNoUndefinedParentID: ${modified}`))
}

View file

@ -1,241 +1,135 @@
import * as Path from 'path'
import { pathExists } from 'fs-extra'
import { IFoundEditor } from './found-editor'
import { assertNever } from '../fatal-error'
import appPath from 'app-path'
export enum ExternalEditor {
Atom = 'Atom',
MacVim = 'MacVim',
VSCode = 'Visual Studio Code',
VSCodeInsiders = 'Visual Studio Code (Insiders)',
VSCodium = 'VSCodium',
SublimeText = 'Sublime Text',
BBEdit = 'BBEdit',
PhpStorm = 'PhpStorm',
PyCharm = 'PyCharm',
RubyMine = 'RubyMine',
TextMate = 'TextMate',
Brackets = 'Brackets',
WebStorm = 'WebStorm',
Typora = 'Typora',
CodeRunner = 'CodeRunner',
SlickEdit = 'SlickEdit',
IntelliJ = 'IntelliJ',
Xcode = 'Xcode',
GoLand = 'GoLand',
AndroidStudio = 'Android Studio',
Rider = 'Rider',
Nova = 'Nova',
/** Represents an external editor on macOS */
interface IDarwinExternalEditor {
/** Name of the editor. It will be used both as identifier and user-facing. */
readonly name: string
/**
* List of bundle identifiers that are used by the app in its multiple
* versions.
**/
readonly bundleIdentifiers: string[]
}
export function parse(label: string): ExternalEditor | null {
if (label === ExternalEditor.Atom) {
return ExternalEditor.Atom
}
if (label === ExternalEditor.MacVim) {
return ExternalEditor.MacVim
}
if (label === ExternalEditor.VSCode) {
return ExternalEditor.VSCode
}
if (label === ExternalEditor.VSCodeInsiders) {
return ExternalEditor.VSCodeInsiders
}
/**
* This list contains all the external editors supported on macOS. Add a new
* entry here to add support for your favorite editor.
**/
const editors: IDarwinExternalEditor[] = [
{
name: 'Atom',
bundleIdentifiers: ['com.github.atom'],
},
{
name: 'MacVim',
bundleIdentifiers: ['org.vim.MacVim'],
},
{
name: 'Visual Studio Code',
bundleIdentifiers: ['com.microsoft.VSCode'],
},
{
name: 'Visual Studio Code (Insiders)',
bundleIdentifiers: ['com.microsoft.VSCodeInsiders'],
},
{
name: 'VSCodium',
bundleIdentifiers: ['com.visualstudio.code.oss'],
},
{
name: 'Sublime Text',
bundleIdentifiers: [
'com.sublimetext.4',
'com.sublimetext.3',
'com.sublimetext.2',
],
},
{
name: 'BBEdit',
bundleIdentifiers: ['com.barebones.bbedit'],
},
{
name: 'PhpStorm',
bundleIdentifiers: ['com.jetbrains.PhpStorm'],
},
{
name: 'PyCharm',
bundleIdentifiers: ['com.jetbrains.PyCharm'],
},
{
name: 'RubyMine',
bundleIdentifiers: ['com.jetbrains.RubyMine'],
},
{
name: 'TextMate',
bundleIdentifiers: ['com.macromates.TextMate'],
},
{
name: 'Brackets',
bundleIdentifiers: ['io.brackets.appshell'],
},
{
name: 'WebStorm',
bundleIdentifiers: ['com.jetbrains.WebStorm'],
},
{
name: 'Typora',
bundleIdentifiers: ['abnerworks.Typora'],
},
{
name: 'CodeRunner',
bundleIdentifiers: ['com.krill.CodeRunner'],
},
{
name: 'SlickEdit',
bundleIdentifiers: [
'com.slickedit.SlickEditPro2018',
'com.slickedit.SlickEditPro2017',
'com.slickedit.SlickEditPro2016',
'com.slickedit.SlickEditPro2015',
],
},
{
name: 'IntelliJ',
bundleIdentifiers: ['com.jetbrains.intellij'],
},
{
name: 'Xcode',
bundleIdentifiers: ['com.apple.dt.Xcode'],
},
{
name: 'GoLand',
bundleIdentifiers: ['com.jetbrains.goland'],
},
{
name: 'Android Studio',
bundleIdentifiers: ['com.google.android.studio'],
},
{
name: 'Rider',
bundleIdentifiers: ['com.jetbrains.rider'],
},
{
name: 'Nova',
bundleIdentifiers: ['com.panic.Nova'],
},
]
if (label === ExternalEditor.VSCodium) {
return ExternalEditor.VSCodium
}
if (label === ExternalEditor.SublimeText) {
return ExternalEditor.SublimeText
}
if (label === ExternalEditor.BBEdit) {
return ExternalEditor.BBEdit
}
if (label === ExternalEditor.PhpStorm) {
return ExternalEditor.PhpStorm
}
if (label === ExternalEditor.PyCharm) {
return ExternalEditor.PyCharm
}
if (label === ExternalEditor.RubyMine) {
return ExternalEditor.RubyMine
}
if (label === ExternalEditor.TextMate) {
return ExternalEditor.TextMate
}
if (label === ExternalEditor.Brackets) {
return ExternalEditor.Brackets
}
if (label === ExternalEditor.WebStorm) {
return ExternalEditor.WebStorm
}
if (label === ExternalEditor.Typora) {
return ExternalEditor.Typora
}
if (label === ExternalEditor.CodeRunner) {
return ExternalEditor.CodeRunner
}
if (label === ExternalEditor.SlickEdit) {
return ExternalEditor.SlickEdit
}
if (label === ExternalEditor.IntelliJ) {
return ExternalEditor.IntelliJ
}
if (label === ExternalEditor.Xcode) {
return ExternalEditor.Xcode
}
if (label === ExternalEditor.GoLand) {
return ExternalEditor.GoLand
}
if (label === ExternalEditor.AndroidStudio) {
return ExternalEditor.AndroidStudio
}
if (label === ExternalEditor.Rider) {
return ExternalEditor.Rider
}
if (label === ExternalEditor.Nova) {
return ExternalEditor.Nova
}
return null
}
function getBundleIdentifiers(editor: ExternalEditor): ReadonlyArray<string> {
switch (editor) {
case ExternalEditor.Atom:
return ['com.github.atom']
case ExternalEditor.MacVim:
return ['org.vim.MacVim']
case ExternalEditor.VSCode:
return ['com.microsoft.VSCode']
case ExternalEditor.VSCodeInsiders:
return ['com.microsoft.VSCodeInsiders']
case ExternalEditor.VSCodium:
return ['com.visualstudio.code.oss']
case ExternalEditor.SublimeText:
return ['com.sublimetext.3']
case ExternalEditor.BBEdit:
return ['com.barebones.bbedit']
case ExternalEditor.PhpStorm:
return ['com.jetbrains.PhpStorm']
case ExternalEditor.PyCharm:
return ['com.jetbrains.PyCharm']
case ExternalEditor.RubyMine:
return ['com.jetbrains.RubyMine']
case ExternalEditor.IntelliJ:
return ['com.jetbrains.intellij']
case ExternalEditor.TextMate:
return ['com.macromates.TextMate']
case ExternalEditor.Brackets:
return ['io.brackets.appshell']
case ExternalEditor.WebStorm:
return ['com.jetbrains.WebStorm']
case ExternalEditor.Typora:
return ['abnerworks.Typora']
case ExternalEditor.CodeRunner:
return ['com.krill.CodeRunner']
case ExternalEditor.SlickEdit:
return [
'com.slickedit.SlickEditPro2018',
'com.slickedit.SlickEditPro2017',
'com.slickedit.SlickEditPro2016',
'com.slickedit.SlickEditPro2015',
]
case ExternalEditor.Xcode:
return ['com.apple.dt.Xcode']
case ExternalEditor.GoLand:
return ['com.jetbrains.goland']
case ExternalEditor.AndroidStudio:
return ['com.google.android.studio']
case ExternalEditor.Rider:
return ['com.jetbrains.rider']
case ExternalEditor.Nova:
return ['com.panic.Nova']
default:
return assertNever(editor, `Unknown external editor: ${editor}`)
}
}
function getExecutableShim(
editor: ExternalEditor,
installPath: string
): string {
switch (editor) {
case ExternalEditor.Atom:
return Path.join(installPath, 'Contents', 'Resources', 'app', 'atom.sh')
case ExternalEditor.VSCode:
case ExternalEditor.VSCodeInsiders:
return Path.join(
installPath,
'Contents',
'Resources',
'app',
'bin',
'code'
)
case ExternalEditor.VSCodium:
return Path.join(
installPath,
'Contents',
'Resources',
'app',
'bin',
'code'
)
case ExternalEditor.MacVim:
return Path.join(installPath, 'Contents', 'MacOS', 'MacVim')
case ExternalEditor.SublimeText:
return Path.join(installPath, 'Contents', 'SharedSupport', 'bin', 'subl')
case ExternalEditor.BBEdit:
return Path.join(installPath, 'Contents', 'Helpers', 'bbedit_tool')
case ExternalEditor.PhpStorm:
return Path.join(installPath, 'Contents', 'MacOS', 'phpstorm')
case ExternalEditor.PyCharm:
return Path.join(installPath, 'Contents', 'MacOS', 'pycharm')
case ExternalEditor.RubyMine:
return Path.join(installPath, 'Contents', 'MacOS', 'rubymine')
case ExternalEditor.TextMate:
return Path.join(installPath, 'Contents', 'Resources', 'mate')
case ExternalEditor.Brackets:
return Path.join(installPath, 'Contents', 'MacOS', 'Brackets')
case ExternalEditor.WebStorm:
return Path.join(installPath, 'Contents', 'MacOS', 'WebStorm')
case ExternalEditor.IntelliJ:
return Path.join(installPath, 'Contents', 'MacOS', 'idea')
case ExternalEditor.Typora:
return Path.join(installPath, 'Contents', 'MacOS', 'Typora')
case ExternalEditor.CodeRunner:
return Path.join(installPath, 'Contents', 'MacOS', 'CodeRunner')
case ExternalEditor.SlickEdit:
return Path.join(installPath, 'Contents', 'MacOS', 'vs')
case ExternalEditor.Xcode:
return '/usr/bin/xed'
case ExternalEditor.GoLand:
return Path.join(installPath, 'Contents', 'MacOS', 'goland')
case ExternalEditor.AndroidStudio:
return Path.join(installPath, 'Contents', 'MacOS', 'studio')
case ExternalEditor.Rider:
return Path.join(installPath, 'Contents', 'MacOS', 'rider')
case ExternalEditor.Nova:
return Path.join(installPath, 'Contents', 'SharedSupport', 'nova')
default:
return assertNever(editor, `Unknown external editor: ${editor}`)
}
}
async function findApplication(editor: ExternalEditor): Promise<string | null> {
const identifiers = getBundleIdentifiers(editor)
for (const identifier of identifiers) {
async function findApplication(
editor: IDarwinExternalEditor
): Promise<string | null> {
for (const identifier of editor.bundleIdentifiers) {
try {
const installPath = await appPath(identifier)
const path = getExecutableShim(editor, installPath)
const exists = await pathExists(path)
const exists = await pathExists(installPath)
if (exists) {
return path
return installPath
}
log.debug(`Command line interface for ${editor} not found at '${path}'`)
log.debug(`App installation for ${editor} not found at '${installPath}'`)
} catch (error) {
log.debug(`Unable to locate ${editor} installation`, error)
}
@ -249,150 +143,16 @@ async function findApplication(editor: ExternalEditor): Promise<string | null> {
* to register itself on a user's machine when installing.
*/
export async function getAvailableEditors(): Promise<
ReadonlyArray<IFoundEditor<ExternalEditor>>
ReadonlyArray<IFoundEditor<string>>
> {
const results: Array<IFoundEditor<ExternalEditor>> = []
const results: Array<IFoundEditor<string>> = []
const [
atomPath,
macVimPath,
codePath,
codeInsidersPath,
codiumPath,
sublimePath,
bbeditPath,
phpStormPath,
pyCharmPath,
rubyMinePath,
textMatePath,
bracketsPath,
webStormPath,
typoraPath,
codeRunnerPath,
slickeditPath,
intellijPath,
xcodePath,
golandPath,
androidStudioPath,
riderPath,
novaPath,
] = await Promise.all([
findApplication(ExternalEditor.Atom),
findApplication(ExternalEditor.MacVim),
findApplication(ExternalEditor.VSCode),
findApplication(ExternalEditor.VSCodeInsiders),
findApplication(ExternalEditor.VSCodium),
findApplication(ExternalEditor.SublimeText),
findApplication(ExternalEditor.BBEdit),
findApplication(ExternalEditor.PhpStorm),
findApplication(ExternalEditor.PyCharm),
findApplication(ExternalEditor.RubyMine),
findApplication(ExternalEditor.TextMate),
findApplication(ExternalEditor.Brackets),
findApplication(ExternalEditor.WebStorm),
findApplication(ExternalEditor.Typora),
findApplication(ExternalEditor.CodeRunner),
findApplication(ExternalEditor.SlickEdit),
findApplication(ExternalEditor.IntelliJ),
findApplication(ExternalEditor.Xcode),
findApplication(ExternalEditor.GoLand),
findApplication(ExternalEditor.AndroidStudio),
findApplication(ExternalEditor.Rider),
findApplication(ExternalEditor.Nova),
])
for (const editor of editors) {
const path = await findApplication(editor)
if (atomPath) {
results.push({ editor: ExternalEditor.Atom, path: atomPath })
}
if (macVimPath) {
results.push({ editor: ExternalEditor.MacVim, path: macVimPath })
}
if (codePath) {
results.push({ editor: ExternalEditor.VSCode, path: codePath })
}
if (codeInsidersPath) {
results.push({
editor: ExternalEditor.VSCodeInsiders,
path: codeInsidersPath,
})
}
if (codiumPath) {
results.push({ editor: ExternalEditor.VSCodium, path: codiumPath })
}
if (sublimePath) {
results.push({ editor: ExternalEditor.SublimeText, path: sublimePath })
}
if (bbeditPath) {
results.push({ editor: ExternalEditor.BBEdit, path: bbeditPath })
}
if (phpStormPath) {
results.push({ editor: ExternalEditor.PhpStorm, path: phpStormPath })
}
if (pyCharmPath) {
results.push({ editor: ExternalEditor.PyCharm, path: pyCharmPath })
}
if (rubyMinePath) {
results.push({ editor: ExternalEditor.RubyMine, path: rubyMinePath })
}
if (textMatePath) {
results.push({ editor: ExternalEditor.TextMate, path: textMatePath })
}
if (bracketsPath) {
results.push({ editor: ExternalEditor.Brackets, path: bracketsPath })
}
if (webStormPath) {
results.push({ editor: ExternalEditor.WebStorm, path: webStormPath })
}
if (typoraPath) {
results.push({ editor: ExternalEditor.Typora, path: typoraPath })
}
if (codeRunnerPath) {
results.push({ editor: ExternalEditor.CodeRunner, path: codeRunnerPath })
}
if (slickeditPath) {
results.push({ editor: ExternalEditor.SlickEdit, path: slickeditPath })
}
if (intellijPath) {
results.push({ editor: ExternalEditor.IntelliJ, path: intellijPath })
}
if (xcodePath) {
results.push({ editor: ExternalEditor.Xcode, path: xcodePath })
}
if (golandPath) {
results.push({ editor: ExternalEditor.GoLand, path: golandPath })
}
if (androidStudioPath) {
results.push({
editor: ExternalEditor.AndroidStudio,
path: androidStudioPath,
})
}
if (riderPath) {
results.push({ editor: ExternalEditor.Rider, path: riderPath })
}
if (novaPath) {
results.push({ editor: ExternalEditor.Nova, path: novaPath })
if (path) {
results.push({ editor: editor.name, path })
}
}
return results

View file

@ -1,3 +1,2 @@
export * from './lookup'
export * from './launch'
export { ExternalEditor, parse } from './shared'

View file

@ -1,4 +1,4 @@
import { spawn } from 'child_process'
import { spawn, SpawnOptions } from 'child_process'
import { pathExists } from 'fs-extra'
import { ExternalEditorError, FoundEditor } from './shared'
@ -21,9 +21,21 @@ export async function launchExternalEditor(
{ openPreferences: true }
)
}
const opts: SpawnOptions = {
// Make sure the editor processes are detached from the Desktop app.
// Otherwise, some editors (like Notepad++) will be killed when the
// Desktop app is closed.
detached: true,
}
if (editor.usesShell) {
spawn(`"${editorPath}"`, [`"${fullPath}"`], { shell: true })
spawn(`"${editorPath}"`, [`"${fullPath}"`], { ...opts, shell: true })
} else if (__DARWIN__) {
// In macOS we can use `open`, which will open the right executable file
// for us, we only need the path to the editor .app folder.
spawn('open', ['-a', editorPath, fullPath], opts)
} else {
spawn(editorPath, [fullPath])
spawn(editorPath, [fullPath], opts)
}
}

View file

@ -1,136 +1,82 @@
import { pathExists } from 'fs-extra'
import { IFoundEditor } from './found-editor'
import { assertNever } from '../fatal-error'
export enum ExternalEditor {
Atom = 'Atom',
VSCode = 'Visual Studio Code',
VSCodeInsiders = 'Visual Studio Code (Insiders)',
VSCodium = 'VSCodium',
SublimeText = 'Sublime Text',
Typora = 'Typora',
SlickEdit = 'SlickEdit',
/** Represents an external editor on Linux */
interface ILinuxExternalEditor {
/** Name of the editor. It will be used both as identifier and user-facing. */
readonly name: string
/** List of possible paths where the editor's executable might be located. */
readonly paths: string[]
}
export function parse(label: string): ExternalEditor | null {
if (label === ExternalEditor.Atom) {
return ExternalEditor.Atom
}
/**
* This list contains all the external editors supported on Linux. Add a new
* entry here to add support for your favorite editor.
**/
const editors: ILinuxExternalEditor[] = [
{
name: 'Atom',
paths: ['/snap/bin/atom', '/usr/bin/atom'],
},
{
name: 'Visual Studio Code',
paths: ['/snap/bin/code', '/usr/bin/code'],
},
{
name: 'Visual Studio Code (Insiders)',
paths: ['/snap/bin/code-insiders', '/usr/bin/code-insiders'],
},
{
name: 'VSCodium',
paths: ['/usr/bin/codium'],
},
{
name: 'Sublime Text',
paths: ['/usr/bin/subl'],
},
{
name: 'Typora',
paths: ['/usr/bin/typora'],
},
{
name: 'SlickEdit',
paths: [
'/opt/slickedit-pro2018/bin/vs',
'/opt/slickedit-pro2017/bin/vs',
'/opt/slickedit-pro2016/bin/vs',
'/opt/slickedit-pro2015/bin/vs',
],
},
{
// Code editor for elementary OS
// https://github.com/elementary/code
name: 'Code',
paths: ['/usr/bin/io.elementary.code'],
},
]
if (label === ExternalEditor.VSCode) {
return ExternalEditor.VSCode
}
if (label === ExternalEditor.VSCodeInsiders) {
return ExternalEditor.VSCode
}
if (label === ExternalEditor.VSCodium) {
return ExternalEditor.VSCodium
}
if (label === ExternalEditor.SublimeText) {
return ExternalEditor.SublimeText
}
if (label === ExternalEditor.Typora) {
return ExternalEditor.Typora
}
if (label === ExternalEditor.SlickEdit) {
return ExternalEditor.SlickEdit
async function getAvailablePath(paths: string[]): Promise<string | null> {
for (const path of paths) {
if (await pathExists(path)) {
return path
}
}
return null
}
async function getPathIfAvailable(path: string): Promise<string | null> {
return (await pathExists(path)) ? path : null
}
async function getEditorPath(editor: ExternalEditor): Promise<string | null> {
switch (editor) {
case ExternalEditor.Atom:
return getPathIfAvailable('/usr/bin/atom')
case ExternalEditor.VSCode:
return getPathIfAvailable('/usr/bin/code')
case ExternalEditor.VSCodeInsiders:
return getPathIfAvailable('/usr/bin/code-insiders')
case ExternalEditor.VSCodium:
return getPathIfAvailable('/usr/bin/codium')
case ExternalEditor.SublimeText:
return getPathIfAvailable('/usr/bin/subl')
case ExternalEditor.Typora:
return getPathIfAvailable('/usr/bin/typora')
case ExternalEditor.SlickEdit:
const possiblePaths = [
'/opt/slickedit-pro2018/bin/vs',
'/opt/slickedit-pro2017/bin/vs',
'/opt/slickedit-pro2016/bin/vs',
'/opt/slickedit-pro2015/bin/vs',
]
for (const possiblePath of possiblePaths) {
const slickeditPath = await getPathIfAvailable(possiblePath)
if (slickeditPath) {
return slickeditPath
}
}
return null
default:
return assertNever(editor, `Unknown editor: ${editor}`)
}
}
export async function getAvailableEditors(): Promise<
ReadonlyArray<IFoundEditor<ExternalEditor>>
ReadonlyArray<IFoundEditor<string>>
> {
const results: Array<IFoundEditor<ExternalEditor>> = []
const results: Array<IFoundEditor<string>> = []
const [
atomPath,
codePath,
codeInsidersPath,
codiumPath,
sublimePath,
typoraPath,
slickeditPath,
] = await Promise.all([
getEditorPath(ExternalEditor.Atom),
getEditorPath(ExternalEditor.VSCode),
getEditorPath(ExternalEditor.VSCodeInsiders),
getEditorPath(ExternalEditor.VSCodium),
getEditorPath(ExternalEditor.SublimeText),
getEditorPath(ExternalEditor.Typora),
getEditorPath(ExternalEditor.SlickEdit),
])
if (atomPath) {
results.push({ editor: ExternalEditor.Atom, path: atomPath })
}
if (codePath) {
results.push({ editor: ExternalEditor.VSCode, path: codePath })
}
if (codeInsidersPath) {
results.push({ editor: ExternalEditor.VSCode, path: codeInsidersPath })
}
if (codiumPath) {
results.push({ editor: ExternalEditor.VSCodium, path: codiumPath })
}
if (sublimePath) {
results.push({ editor: ExternalEditor.SublimeText, path: sublimePath })
}
if (typoraPath) {
results.push({ editor: ExternalEditor.Typora, path: typoraPath })
}
if (slickeditPath) {
results.push({ editor: ExternalEditor.SlickEdit, path: slickeditPath })
for (const editor of editors) {
const path = await getAvailablePath(editor.paths)
if (path) {
results.push({ editor: editor.name, path })
}
}
return results

View file

@ -1,17 +1,17 @@
import { ExternalEditor, ExternalEditorError } from './shared'
import { ExternalEditorError } from './shared'
import { IFoundEditor } from './found-editor'
import { getAvailableEditors as getAvailableEditorsDarwin } from './darwin'
import { getAvailableEditors as getAvailableEditorsWindows } from './win32'
import { getAvailableEditors as getAvailableEditorsLinux } from './linux'
let editorCache: ReadonlyArray<IFoundEditor<ExternalEditor>> | null = null
let editorCache: ReadonlyArray<IFoundEditor<string>> | null = null
/**
* Resolve a list of installed editors on the user's machine, using the known
* install identifiers that each OS supports.
*/
export async function getAvailableEditors(): Promise<
ReadonlyArray<IFoundEditor<ExternalEditor>>
ReadonlyArray<IFoundEditor<string>>
> {
if (editorCache && editorCache.length > 0) {
return editorCache
@ -48,7 +48,7 @@ export async function getAvailableEditors(): Promise<
*/
export async function findEditorOrDefault(
name: string | null
): Promise<IFoundEditor<ExternalEditor> | null> {
): Promise<IFoundEditor<string> | null> {
const editors = await getAvailableEditors()
if (editors.length === 0) {
return null

View file

@ -1,24 +1,3 @@
import * as Darwin from './darwin'
import * as Win32 from './win32'
import * as Linux from './linux'
export type ExternalEditor = Darwin.ExternalEditor | Win32.ExternalEditor
/** Parse the label into the specified shell type. */
export function parse(label: string): ExternalEditor | null {
if (__DARWIN__) {
return Darwin.parse(label)
} else if (__WIN32__) {
return Win32.parse(label)
} else if (__LINUX__) {
return Linux.parse(label)
}
throw new Error(
`Platform not currently supported for resolving editors: ${process.platform}`
)
}
/**
* A found external editor on the user's machine
*/
@ -26,7 +5,7 @@ export type FoundEditor = {
/**
* The friendly name of the editor, to be used in labels
*/
editor: ExternalEditor
editor: string
/**
* The executable associated with the editor to launch
*/
@ -38,8 +17,8 @@ export type FoundEditor = {
}
interface IErrorMetadata {
/** The error dialog should link off to the Atom website */
suggestAtom?: boolean
/** The error dialog should link off to the default editor's website */
suggestDefaultEditor?: boolean
/** The error dialog should direct the user to open Preferences */
openPreferences?: boolean
@ -55,3 +34,8 @@ export class ExternalEditorError extends Error {
this.metadata = metadata
}
}
export const suggestedExternalEditor = {
name: 'Visual Studio Code',
url: 'https://code.visualstudio.com',
}

File diff suppressed because it is too large Load diff

View file

@ -44,7 +44,7 @@ export function lookupPreferredEmail(account: Account): string {
*/
function isEmailPublic(email: IAPIEmail): boolean {
// If an email doesn't have a visibility setting it means it's coming from an
// older Enterprise Server which doesn't have the concept of visibility.
// older Enterprise version which doesn't have the concept of visibility.
return email.visibility === 'public' || !email.visibility
}
@ -67,7 +67,7 @@ function getStealthEmailHostForEndpoint(endpoint: string) {
*
* @param login The user handle or "login"
* @param endpoint The API endpoint that this login belongs to,
* either GitHub.com or a GitHub Enterprise Server
* either GitHub.com or a GitHub Enterprise
* instance
*/
export function getLegacyStealthEmailForUser(login: string, endpoint: string) {
@ -86,7 +86,7 @@ export function getLegacyStealthEmailForUser(login: string, endpoint: string) {
* is available.
* @param login The user handle or "login"
* @param endpoint The API endpoint that this login belongs to,
* either GitHub.com or a GitHub Enterprise Server
* either GitHub.com or a GitHub Enterprise
* instance
*/
export function getStealthEmailForUser(
@ -101,7 +101,7 @@ export function getStealthEmailForUser(
/**
* Produces a list of all email addresses that when used as the author email
* in a commit we'll know will end up getting attributed to the given
* account when pushed to GitHub.com or GitHub Enterprise Server.
* account when pushed to GitHub.com or GitHub Enterprise.
*
* The list of email addresses consists of all the email addresses we get
* from the API (since this is for the currently signed in user we get

View file

@ -1,5 +1,5 @@
/**
* The oldest officially supported version of GitHub Enterprise Server.
* The oldest officially supported version of GitHub Enterprise.
* This information is used in user-facing text and shouldn't be
* considered a hard limit, i.e. older versions of GitHub Enterprise
* might (and probably do) work just fine but this should be a fairly

10
app/src/lib/enum.ts Normal file
View file

@ -0,0 +1,10 @@
/**
* Parse a string into the given (string) enum type. Returns undefined if the
* enum type provided did not match any of the keys in the enum.
*/
export function parseEnumValue<T extends string>(
enumObj: Record<string, T>,
value: string
): T | undefined {
return Object.values(enumObj).find(v => v === value)
}

View file

@ -1,7 +1,8 @@
import { Repository } from '../models/repository'
import { CloningRepository } from '../models/cloning-repository'
import { RetryAction } from '../models/retry-actions'
import { RetryAction, RetryActionType } from '../models/retry-actions'
import { GitErrorContext } from './git-error-context'
import { Branch } from '../models/branch'
export interface IErrorMetadata {
/** Was the action which caused this error part of a background task? */
@ -34,3 +35,17 @@ export class ErrorWithMetadata extends Error {
this.metadata = metadata
}
}
/**
* An error thrown when a failure occurs while checking out a branch.
* Technically just a convience class on top of ErrorWithMetadata
*/
export class CheckoutError extends ErrorWithMetadata {
public constructor(error: Error, repository: Repository, branch: Branch) {
super(error, {
gitContext: { kind: 'checkout', branchToCheckout: branch },
retryAction: { type: RetryActionType.Checkout, branch, repository },
repository,
})
}
}

View file

@ -85,16 +85,6 @@ export function enableForkyCreateBranchUI(): boolean {
return true
}
/**
* Should we show the NDDB banner?
*
* (It's a notification in the history sidebar that there
* are new commits upstream.)
*/
export function enableNDDBBanner(): boolean {
return false
}
/**
* Should we show the git tag information in the app UI?
*/
@ -146,3 +136,10 @@ export function enableExperimentalDiffViewer(): boolean {
export function enableDefaultBranchSetting(): boolean {
return true
}
/**
* Should we allow reporting unhandled rejections as if they were crashes?
*/
export function enableUnhandledRejectionReporting(): boolean {
return enableBetaFeatures()
}

View file

@ -74,11 +74,11 @@ export async function findAccountForRemoteURL(
// This chunk of code is designed to sort the user's accounts in this order:
// - authenticated GitHub account
// - GitHub Enterprise Server accounts
// - GitHub Enterprise accounts
// - unauthenticated GitHub account (access public repositories)
//
// As this needs to be done efficiently, we consider endpoints not matching
// `getDotComAPIEndpoint()` to be GitHub Enterprise Server accounts, and accounts
// `getDotComAPIEndpoint()` to be GitHub Enterprise accounts, and accounts
// without a token to be unauthenticated.
const sortedAccounts = Array.from(allAccounts).sort((a1, a2) => {
if (a1.endpoint === getDotComAPIEndpoint()) {

View file

@ -6,7 +6,7 @@ import { getDotComAPIEndpoint } from './api'
* Generate a human-friendly description of the Account endpoint.
*
* Accounts on GitHub.com will return the string 'GitHub.com'
* whereas GitHub Enterprise Server accounts will return the
* whereas GitHub Enterprise accounts will return the
* hostname without the protocol and/or path.
*/
export function friendlyEndpointName(account: Account) {

View file

@ -1,7 +1,6 @@
import { git, gitNetworkArguments } from './core'
import { getBranches } from './for-each-ref'
import { Repository } from '../../models/repository'
import { Branch, BranchType } from '../../models/branch'
import { Branch } from '../../models/branch'
import { IGitAccount } from '../../models/git-account'
import { formatAsLocalRef } from './refs'
import { deleteRef } from './update-ref'
@ -11,6 +10,7 @@ import {
envForRemoteOperation,
getFallbackUrlForProxyResolve,
} from './environment'
import { createForEachRefParser } from './git-delimiter-parser'
/**
* Create a new branch from the given start point.
@ -26,7 +26,7 @@ export async function createBranch(
name: string,
startPoint: string | null,
noTrack?: boolean
): Promise<Branch | null> {
): Promise<void> {
const args =
startPoint !== null ? ['branch', name, startPoint] : ['branch', name]
@ -38,12 +38,6 @@ export async function createBranch(
}
await git(args, repository.path, 'createBranch')
const branches = await getBranches(repository, `refs/heads/${name}`)
if (branches.length > 0) {
return branches[0]
}
return null
}
/** Rename the given branch to a new name. */
@ -60,8 +54,7 @@ export async function renameBranch(
}
/**
* Delete the branch locally, see `deleteBranch` if you're looking to delete the
* branch from the remote as well.
* Delete the branch locally.
*/
export async function deleteLocalBranch(
repository: Repository,
@ -72,54 +65,43 @@ export async function deleteLocalBranch(
}
/**
* Delete the branch. If the branch has a remote branch and `includeRemote` is true, it too will be
* deleted. Silently deletes local branch if remote one is already deleted.
* Deletes a remote branch
*
* @param remoteName - the name of the remote to delete the branch from
* @param remoteBranchName - the name of the branch on the remote
*/
export async function deleteBranch(
export async function deleteRemoteBranch(
repository: Repository,
branch: Branch,
account: IGitAccount | null,
includeRemote: boolean
remoteName: string,
remoteBranchName: string
): Promise<true> {
if (branch.type === BranchType.Local) {
await deleteLocalBranch(repository, branch.name)
}
const networkArguments = await gitNetworkArguments(repository, account)
const remoteUrl =
(await getRemoteURL(repository, remoteName).catch(err => {
// If we can't get the URL then it's very unlikely Git will be able to
// either and the push will fail. The URL is only used to resolve the
// proxy though so it's not critical.
log.error(`Could not resolve remote url for remote ${remoteName}`, err)
return null
})) || getFallbackUrlForProxyResolve(account, repository)
const remoteName = branch.remote
const args = [...networkArguments, 'push', remoteName, `:${remoteBranchName}`]
if (includeRemote && remoteName) {
const networkArguments = await gitNetworkArguments(repository, account)
const remoteUrl =
(await getRemoteURL(repository, remoteName).catch(err => {
// If we can't get the URL then it's very unlikely Git will be able to
// either and the push will fail. The URL is only used to resolve the
// proxy though so it's not critical.
log.error(`Could not resolve remote url for remote ${remoteName}`, err)
return null
})) || getFallbackUrlForProxyResolve(account, repository)
// If the user is not authenticated, the push is going to fail
// Let this propagate and leave it to the caller to handle
const result = await git(args, repository.path, 'deleteRemoteBranch', {
env: await envForRemoteOperation(account, remoteUrl),
expectedErrors: new Set<DugiteError>([DugiteError.BranchDeletionFailed]),
})
const args = [
...networkArguments,
'push',
remoteName,
`:${branch.nameWithoutRemote}`,
]
// If the user is not authenticated, the push is going to fail
// Let this propagate and leave it to the caller to handle
const result = await git(args, repository.path, 'deleteRemoteBranch', {
env: await envForRemoteOperation(account, remoteUrl),
expectedErrors: new Set<DugiteError>([DugiteError.BranchDeletionFailed]),
})
// It's possible that the delete failed because the ref has already
// been deleted on the remote. If we identify that specific
// error we can safely remote our remote ref which is what would
// happen if the push didn't fail.
if (result.gitError === DugiteError.BranchDeletionFailed) {
const ref = `refs/remotes/${remoteName}/${branch.nameWithoutRemote}`
await deleteRef(repository, ref)
}
// It's possible that the delete failed because the ref has already
// been deleted on the remote. If we identify that specific
// error we can safely remove our remote ref which is what would
// happen if the push didn't fail.
if (result.gitError === DugiteError.BranchDeletionFailed) {
const ref = `refs/remotes/${remoteName}/${remoteBranchName}`
await deleteRef(repository, ref)
}
return true
@ -172,35 +154,21 @@ export async function getMergedBranches(
branchName: string
): Promise<Map<string, string>> {
const canonicalBranchRef = formatAsLocalRef(branchName)
const { formatArgs, parse } = createForEachRefParser({
sha: '%(objectname)',
canonicalRef: '%(refname)',
})
const args = [
'branch',
`--format=%(objectname)%00%(refname)`,
'--merged',
branchName,
]
const { stdout } = await git(args, repository.path, 'mergedBranches')
const lines = stdout.split('\n')
// Remove the trailing newline
lines.splice(-1, 1)
const args = ['branch', ...formatArgs, '--merged', branchName]
const mergedBranches = new Map<string, string>()
const { stdout } = await git(args, repository.path, 'mergedBranches')
for (const line of lines) {
const [sha, canonicalRef] = line.split('\0')
if (sha === undefined || canonicalRef === undefined) {
continue
}
for (const branch of parse(stdout)) {
// Don't include the branch we're using to compare against
// in the list of branches merged into that branch.
if (canonicalRef === canonicalBranchRef) {
continue
if (branch.canonicalRef !== canonicalBranchRef) {
mergedBranches.set(branch.canonicalRef, branch.sha)
}
mergedBranches.set(canonicalRef, sha)
}
return mergedBranches

View file

@ -123,23 +123,6 @@ export async function checkoutPaths(
)
}
/**
* Create and checkout the given branch.
*
* @param repository The repository.
* @param branchName The branch to create and checkout.
*/
export async function createAndCheckoutBranch(
repository: Repository,
branchName: string
): Promise<void> {
await git(
['checkout', '-b', branchName],
repository.path,
'createAndCheckoutBranch'
)
}
/**
* Check out either stage #2 (ours) or #3 (theirs) for a conflicted
* file.

View file

@ -0,0 +1,47 @@
import { Repository } from '../../models/repository'
import { git, IGitResult } from './core'
/** The app-specific results from attempting to cherry pick commits*/
export enum CherryPickResult {
/**
* Git completed the cherry pick without reporting any errors, and the caller can
* signal success to the user.
*/
CompletedWithoutError = 'CompletedWithoutError',
/**
* An unexpected error as part of the cherry pick flow was caught and handled.
*
* Check the logs to find the relevant Git details.
*/
Error = 'Error',
}
/**
* A stub function to initiate cherry picking in the app.
*
* @param revisionRange - this could be a single commit sha or could be a range
* of commits like sha1..sha2 or inclusively sha1^..sha2
*/
export async function cherryPick(
repository: Repository,
revisionRange: string
): Promise<CherryPickResult> {
const result = await git(
['cherry-pick', revisionRange],
repository.path,
'cherry pick'
)
return parseCherryPickResult(result)
}
function parseCherryPickResult(result: IGitResult): CherryPickResult {
if (result.exitCode === 0) {
return CherryPickResult.CompletedWithoutError
}
// TODO: handle known exceptions
throw new Error(`Unhandled result found: '${JSON.stringify(result)}'`)
}

View file

@ -3,6 +3,7 @@ import { ICloneProgress } from '../../models/progress'
import { CloneOptions } from '../../models/clone-options'
import { CloneProgressParser, executionOptionsWithProgress } from '../progress'
import { envForRemoteOperation } from './environment'
import { getDefaultBranch } from '../helpers/default-branch'
/**
* Clones a repository from a given url into to the specified path.
@ -34,7 +35,15 @@ export async function clone(
const env = await envForRemoteOperation(options.account, url)
const args = [...networkArguments, 'clone', '--recursive']
const defaultBranch = options.defaultBranch ?? (await getDefaultBranch())
const args = [
...networkArguments,
'-c',
`init.defaultBranch=${defaultBranch}`,
'clone',
'--recursive',
]
let opts: IGitExecutionOptions = { env }

View file

@ -1,4 +1,4 @@
import { git, GitError, parseCommitSHA } from './core'
import { git, parseCommitSHA } from './core'
import { stageFiles } from './update-index'
import { Repository } from '../../models/repository'
import { WorkingDirectoryFileChange } from '../../models/status'
@ -15,7 +15,7 @@ export async function createCommit(
repository: Repository,
message: string,
files: ReadonlyArray<WorkingDirectoryFileChange>
): Promise<string | undefined> {
): Promise<string> {
// Clear the staging area, our diffs reflect the difference between the
// working directory and the last commit (if any) so our commits should
// do the same thing.
@ -23,20 +23,15 @@ export async function createCommit(
await stageFiles(repository, files)
try {
const result = await git(
['commit', '-F', '-'],
repository.path,
'createCommit',
{
stdin: message,
}
)
return parseCommitSHA(result)
} catch (e) {
logCommitError(e)
return undefined
}
const result = await git(
['commit', '-F', '-'],
repository.path,
'createCommit',
{
stdin: message,
}
)
return parseCommitSHA(result)
}
/**
@ -51,80 +46,54 @@ export async function createMergeCommit(
repository: Repository,
files: ReadonlyArray<WorkingDirectoryFileChange>,
manualResolutions: ReadonlyMap<string, ManualConflictResolution> = new Map()
): Promise<string | undefined> {
try {
// apply manual conflict resolutions
for (const [path, resolution] of manualResolutions) {
const file = files.find(f => f.path === path)
if (file !== undefined) {
await stageManualConflictResolution(repository, file, resolution)
} else {
log.error(
`couldn't find file ${path} even though there's a manual resolution for it`
)
}
): Promise<string> {
// apply manual conflict resolutions
for (const [path, resolution] of manualResolutions) {
const file = files.find(f => f.path === path)
if (file !== undefined) {
await stageManualConflictResolution(repository, file, resolution)
} else {
log.error(
`couldn't find file ${path} even though there's a manual resolution for it`
)
}
const otherFiles = files.filter(f => !manualResolutions.has(f.path))
await stageFiles(repository, otherFiles)
const result = await git(
[
'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'
)
return parseCommitSHA(result)
} catch (e) {
logCommitError(e)
return undefined
}
}
/**
* Commit failures could come from a pre-commit hook rejection.
* So display a bit more context than we otherwise would,
* then re-raise the error.
*/
function logCommitError(e: Error): void {
if (e instanceof GitError) {
const output = e.result.stderr.trim()
const otherFiles = files.filter(f => !manualResolutions.has(f.path))
const standardError = output.length > 0 ? `, with output: '${output}'` : ''
const { exitCode } = e.result
const error = new Error(
`Commit failed - exit code ${exitCode} received${standardError}`
)
error.name = 'commit-failed'
throw error
} else {
throw e
}
await stageFiles(repository, otherFiles)
const result = await git(
[
'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'
)
return parseCommitSHA(result)
}

View file

@ -15,6 +15,9 @@ import * as Path from 'path'
import { Repository } from '../../models/repository'
import { getConfigValue, getGlobalConfigValue } from './config'
import { isErrnoException } from '../errno-exception'
import { ChildProcess } from 'child_process'
import { Readable } from 'stream'
import split2 from 'split2'
/**
* An extension of the execution options in dugite that
@ -54,6 +57,9 @@ export interface IGitResult extends DugiteResult {
/** The human-readable error description, based on `gitError`. */
readonly gitErrorDescription: string | null
/** Both stdout and stderr combined. */
readonly combinedOutput: string
/**
* The path that the Git command was executed from, i.e. the
* process working directory (not to be confused with the Git
@ -61,22 +67,6 @@ export interface IGitResult extends DugiteResult {
*/
readonly path: string
}
function getResultMessage(result: IGitResult) {
const description = result.gitErrorDescription
if (description) {
return description
}
if (result.stderr.length) {
return result.stderr
} else if (result.stdout.length) {
return result.stdout
} else {
return 'Unknown error'
}
}
export class GitError extends Error {
/** The result from the failed command. */
public readonly result: IGitResult
@ -84,12 +74,35 @@ export class GitError extends Error {
/** The args for the failed command. */
public readonly args: ReadonlyArray<string>
/**
* Whether or not the error message is just the raw output of the git command.
*/
public readonly isRawMessage: boolean
public constructor(result: IGitResult, args: ReadonlyArray<string>) {
super(getResultMessage(result))
let rawMessage = true
let message
if (result.gitErrorDescription) {
message = result.gitErrorDescription
rawMessage = false
} else if (result.combinedOutput.length > 0) {
message = result.combinedOutput
} else if (result.stderr.length) {
message = result.stderr
} else if (result.stdout.length) {
message = result.stdout
} else {
message = 'Unknown error'
rawMessage = false
}
super(message)
this.name = 'GitError'
this.result = result
this.args = args
this.isRawMessage = rawMessage
}
}
@ -123,8 +136,24 @@ export async function git(
expectedErrors: new Set(),
}
let combinedOutput = ''
const opts = { ...defaultOptions, ...options }
opts.processCallback = (process: ChildProcess) => {
options?.processCallback?.(process)
const combineOutput = (readable: Readable | null) => {
if (readable) {
readable.pipe(split2()).on('data', (line: string) => {
combinedOutput += line + '\n'
})
}
}
combineOutput(process.stderr)
combineOutput(process.stdout)
}
// Explicitly set TERM to 'dumb' so that if Desktop was launched
// from a terminal or if the system environment variables
// have TERM set Git won't consider us as a smart terminal.
@ -160,7 +189,13 @@ export async function git(
}
const gitErrorDescription = gitError ? getDescriptionForError(gitError) : null
const gitResult = { ...result, gitError, gitErrorDescription, path }
const gitResult = {
...result,
gitError,
gitErrorDescription,
combinedOutput,
path,
}
let acceptableError = true
if (gitError && opts.expectedErrors) {

View file

@ -6,6 +6,7 @@ import { FetchProgressParser, executionOptionsWithProgress } from '../progress'
import { enableRecurseSubmodulesFlag } from '../feature-flag'
import { IRemote } from '../../models/remote'
import { envForRemoteOperation } from './environment'
import { ITrackingBranch } from '../../models/branch'
async function getFetchArgs(
repository: Repository,
@ -128,3 +129,45 @@ export async function fetchRefspec(
await git(args, repository.path, 'fetchRefspec', options)
}
export async function fastForwardBranches(
repository: Repository,
branches: ReadonlyArray<ITrackingBranch>
): Promise<void> {
if (branches.length === 0) {
return
}
const refPairs = branches.map(branch => `${branch.upstreamRef}:${branch.ref}`)
const opts: IGitExecutionOptions = {
// Fetch exits with an exit code of 1 if one or more refs failed to update
// which is what we expect will happen
successExitCodes: new Set([0, 1]),
env: {
// This will make sure the reflog entries are correct after
// fast-forwarding the branches.
GIT_REFLOG_ACTION: 'pull',
},
stdin: refPairs.join('\n'),
}
await git(
[
'fetch',
'.',
// Make sure we don't try to update branches that can't be fast-forwarded
// even if the user disabled this via the git config option
// `fetch.showForcedUpdates`
'--show-forced-updates',
// Prevent `git fetch` from touching the `FETCH_HEAD`
'--no-write-fetch-head',
// Take branch refs from stdin to circumvent shell max line length
// limitations (mainly on Windows)
'--stdin',
],
repository.path,
'fastForwardBranches',
opts
)
}

View file

@ -1,32 +1,28 @@
import { git } from './core'
import { GitError } from 'dugite'
import { Repository } from '../../models/repository'
import { Branch, BranchType } from '../../models/branch'
import {
Branch,
BranchType,
IBranchTip,
ITrackingBranch,
} from '../../models/branch'
import { CommitIdentity } from '../../models/commit-identity'
import { ForkedRemotePrefix } from '../../models/remote'
const ForksReferencesPrefix = `refs/remotes/${ForkedRemotePrefix}`
import { createForEachRefParser } from './git-delimiter-parser'
/** Get all the branches. */
export async function getBranches(
repository: Repository,
...prefixes: string[]
): Promise<ReadonlyArray<Branch>> {
const delimiter = '1F'
const delimiterString = String.fromCharCode(parseInt(delimiter, 16))
const format = [
'%(refname)',
'%(refname:short)',
'%(upstream:short)',
'%(objectname)', // SHA
'%(objectname:short)', // short SHA
'%(author)',
'%(committer)',
'%(symref)',
`%${delimiter}`, // indicate end-of-line as %(body) may contain newlines
].join('%00')
const { formatArgs, parse } = createForEachRefParser({
fullName: '%(refname)',
shortName: '%(refname:short)',
upstreamShortName: '%(upstream:short)',
sha: '%(objectname)',
author: '%(author)',
symRef: '%(symref)',
})
if (!prefixes || !prefixes.length) {
prefixes = ['refs/heads', 'refs/remotes']
@ -36,7 +32,7 @@ export async function getBranches(
// see https://github.com/desktop/desktop/pull/5299#discussion_r206603442 for
// discussion about what needs to change
const result = await git(
['for-each-ref', `--format=${format}`, ...prefixes],
['for-each-ref', ...formatArgs, ...prefixes],
repository.path,
'getBranches',
{ expectedErrors: new Set([GitError.NotAGitRepository]) }
@ -46,69 +42,106 @@ export async function getBranches(
return []
}
const names = result.stdout
const lines = names.split(delimiterString)
// Remove the trailing newline
lines.splice(-1, 1)
if (lines.length === 0) {
return []
}
const branches = []
for (const [ix, line] of lines.entries()) {
// preceding newline character after first row
const pieces = (ix > 0 ? line.substr(1) : line).split('\0')
const ref = pieces[0]
const name = pieces[1]
const upstream = pieces[2]
const sha = pieces[3]
const shortSha = pieces[4]
const authorIdentity = pieces[5]
const author = CommitIdentity.parseIdentity(authorIdentity)
if (!author) {
throw new Error(`Couldn't parse author identity for '${shortSha}'`)
for (const ref of parse(result.stdout)) {
// excude symbolic refs from the branch list
if (ref.symRef.length > 0) {
continue
}
const committerIdentity = pieces[6]
const committer = CommitIdentity.parseIdentity(committerIdentity)
const author = CommitIdentity.parseIdentity(ref.author)
const tip: IBranchTip = { sha: ref.sha, author }
if (!committer) {
throw new Error(`Couldn't parse committer identity for '${shortSha}'`)
}
const symref = pieces[7]
const branchTip = {
sha,
author,
}
const type = ref.startsWith('refs/head')
const type = ref.fullName.startsWith('refs/heads')
? BranchType.Local
: BranchType.Remote
if (symref.length > 0) {
// exclude symbolic refs from the branch list
continue
}
const upstream =
ref.upstreamShortName.length > 0 ? ref.upstreamShortName : null
if (ref.startsWith(ForksReferencesPrefix)) {
// hide refs from our known remotes as these are considered plumbing
// and can add noise to everywhere in the user interface where we
// display branches as forks will likely contain duplicates of the same
// ref names
continue
}
branches.push(
new Branch(name, upstream.length > 0 ? upstream : null, branchTip, type)
)
branches.push(new Branch(ref.shortName, upstream, tip, type, ref.fullName))
}
return branches
}
/**
* Gets all branches that differ from their upstream (i.e. they're ahead,
* behind or both), excluding the current branch.
* Useful to narrow down a list of branches that could potentially be fast
* forwarded.
*
* @param repository Repository to get the branches from.
*/
export async function getBranchesDifferingFromUpstream(
repository: Repository
): Promise<ReadonlyArray<ITrackingBranch>> {
const { formatArgs, parse } = createForEachRefParser({
fullName: '%(refname)',
sha: '%(objectname)', // SHA
upstream: '%(upstream)',
symref: '%(symref)',
head: '%(HEAD)',
})
const prefixes = ['refs/heads', 'refs/remotes']
const result = await git(
['for-each-ref', ...formatArgs, ...prefixes],
repository.path,
'getBranchesDifferingFromUpstream',
{ expectedErrors: new Set([GitError.NotAGitRepository]) }
)
if (result.gitError === GitError.NotAGitRepository) {
return []
}
const localBranches = []
const remoteBranchShas = new Map<string, string>()
// First we need to collect the relevant info from the command output:
// - For local branches with upstream: name, ref, SHA and the upstream.
// - For remote branches we only need the sha (and the ref as key).
for (const ref of parse(result.stdout)) {
if (ref.symref.length > 0 || ref.head === '*') {
// Exclude symbolic refs and the current branch
continue
}
if (ref.fullName.startsWith('refs/heads')) {
if (ref.upstream.length === 0) {
// Exclude local branches without upstream
continue
}
localBranches.push({
ref: ref.fullName,
sha: ref.sha,
upstream: ref.upstream,
})
} else {
remoteBranchShas.set(ref.fullName, ref.sha)
}
}
const eligibleBranches = new Array<ITrackingBranch>()
// Compare the SHA of every local branch with the SHA of its upstream and
// collect the names of local branches that differ from their upstream.
for (const branch of localBranches) {
const remoteSha = remoteBranchShas.get(branch.upstream)
if (remoteSha !== undefined && remoteSha !== branch.sha) {
eligibleBranches.push({
ref: branch.ref,
sha: branch.sha,
upstreamRef: branch.upstream,
upstreamSha: remoteSha,
})
}
}
return eligibleBranches
}

View file

@ -0,0 +1,93 @@
/**
* Create a new parser suitable for parsing --format output from commands such
* as `git log`, `git stash`, and other commands that are not derived from
* `ref-filter`.
*
* Returns an object with the arguments that need to be appended to the git
* call and the parse function itself
*
* @param fields An object keyed on the friendly name of the value being
* parsed with the value being the format string of said value.
*
* Example:
*
* `const { args, parse } = createLogParser({ sha: '%H' })`
*
*/
export function createLogParser<T extends Record<string, string>>(fields: T) {
const keys: Array<keyof T> = Object.keys(fields)
const format = Object.values(fields).join('%x00')
const formatArgs = ['-z', `--format=${format}`]
const parse = (value: string) => {
const records = value.split('\0')
const entries = []
for (let i = 0; i < records.length - keys.length; i += keys.length) {
const entry = {} as { [K in keyof T]: string }
keys.forEach((key, ix) => (entry[key] = records[i + ix]))
entries.push(entry)
}
return entries
}
return { formatArgs, parse }
}
/**
* Create a new parser suitable for parsing --format output from commands such
* as `git for-each-ref`, `git branch`, and other commands that are not derived
* from `git log`.
*
* Returns an object with the arguments that need to be appended to the git
* call and the parse function itself
*
* @param fields An object keyed on the friendly name of the value being
* parsed with the value being the format string of said value.
*
* Example:
*
* `const { args, parse } = createForEachRefParser({ sha: '%(objectname)' })`
*
*/
export function createForEachRefParser<T extends Record<string, string>>(
fields: T
) {
const keys: Array<keyof T> = Object.keys(fields)
const format = Object.values(fields).join('%00')
const formatArgs = [`--format=%00${format}%00`]
const parse = (value: string) => {
const records = value.split('\0')
const entries = new Array<{ [K in keyof T]: string }>()
let entry
let consumed = 0
// start at 1 to avoid 0 modulo X problem. The first record is guaranteed
// to be empty anyway (due to %00 at the start of --format)
for (let i = 1; i < records.length - 1; i++) {
if (i % (keys.length + 1) === 0) {
if (records[i] !== '\n') {
throw new Error('Expected newline')
}
continue
}
entry = entry ?? ({} as { [K in keyof T]: string })
const key = keys[consumed % keys.length]
entry[key] = records[i]
consumed++
if (consumed % keys.length === 0) {
entries.push(entry)
entry = undefined
}
}
return entries
}
return { formatArgs, parse }
}

View file

@ -1,6 +1,11 @@
import { getDefaultBranch } from '../helpers/default-branch'
import { git } from './core'
/** Init a new git repository in the given path. */
export async function initGitRepository(path: string): Promise<void> {
await git(['init'], path, 'initGitRepository')
await git(
['-c', `init.defaultBranch=${await getDefaultBranch()}`, 'init'],
path,
'initGitRepository'
)
}

View file

@ -14,6 +14,7 @@ import {
parseRawUnfoldedTrailers,
} from './interpret-trailers'
import { getCaptures } from '../helpers/regex'
import { createLogParser } from './git-delimiter-parser'
/**
* Map the raw status text from Git to an app-friendly value
@ -66,22 +67,20 @@ export async function getCommits(
limit: number,
additionalArgs: ReadonlyArray<string> = []
): Promise<ReadonlyArray<Commit>> {
const delimiter = '1F'
const delimiterString = String.fromCharCode(parseInt(delimiter, 16))
const prettyFormat = [
'%H', // SHA
'%h', // short SHA
'%s', // summary
'%b', // body
const { formatArgs, parse } = createLogParser({
sha: '%H', // SHA
shortSha: '%h', // short SHA
summary: '%s', // summary
body: '%b', // body
// author identity string, matching format of GIT_AUTHOR_IDENT.
// author name <author email> <author date>
// author date format dependent on --date arg, should be raw
'%an <%ae> %ad',
'%cn <%ce> %cd',
'%P', // parent SHAs,
'%(trailers:unfold,only)',
'%D', // refs
].join(`%x${delimiter}`)
author: '%an <%ae> %ad',
committer: '%cn <%ce> %cd',
parents: '%P', // parent SHAs,
trailers: '%(trailers:unfold,only)',
refs: '%D',
})
const result = await git(
[
@ -89,8 +88,7 @@ export async function getCommits(
revisionRange,
`--date=raw`,
`--max-count=${limit}`,
`--pretty=${prettyFormat}`,
'-z',
...formatArgs,
'--no-show-signature',
'--no-color',
...additionalArgs,
@ -106,58 +104,26 @@ export async function getCommits(
return new Array<Commit>()
}
const out = result.stdout
const lines = out.split('\0')
// Remove the trailing empty line
lines.splice(-1, 1)
if (lines.length === 0) {
return []
}
const trailerSeparators = await getTrailerSeparatorCharacters(repository)
const parsed = parse(result.stdout)
const commits = lines.map(line => {
const pieces = line.split(delimiterString)
const sha = pieces[0]
const shortSha = pieces[1]
const summary = pieces[2]
const body = pieces[3]
const authorIdentity = pieces[4]
const committerIdentity = pieces[5]
const shaList = pieces[6]
const parentSHAs = shaList.length ? shaList.split(' ') : []
const trailers = parseRawUnfoldedTrailers(pieces[7], trailerSeparators)
const tags = getCaptures(pieces[8], /tag: ([^\s,]+)/g)
return parsed.map(commit => {
const tags = getCaptures(commit.refs, /tag: ([^\s,]+)/g)
.filter(i => i[0] !== undefined)
.map(i => i[0])
const author = CommitIdentity.parseIdentity(authorIdentity)
if (!author) {
throw new Error(`Couldn't parse author identity for '${shortSha}'`)
}
const committer = CommitIdentity.parseIdentity(committerIdentity)
if (!committer) {
throw new Error(`Couldn't parse committer identity for '${shortSha}'`)
}
return new Commit(
sha,
shortSha,
summary,
body,
author,
committer,
parentSHAs,
trailers,
commit.sha,
commit.shortSha,
commit.summary,
commit.body,
CommitIdentity.parseIdentity(commit.author),
CommitIdentity.parseIdentity(commit.committer),
commit.parents.length > 0 ? commit.parents.split(' ') : [],
parseRawUnfoldedTrailers(commit.trailers, trailerSeparators),
tags
)
})
return commits
}
/** Get the files that were changed in the given commit. */

View file

@ -30,7 +30,7 @@ import {
import { stageManualConflictResolution } from './stage'
import { stageFiles } from './update-index'
import { getStatus } from './status'
import { getCommitsInRange } from './rev-list'
import { getCommitsBetweenCommits } from './rev-list'
import { Branch } from '../../models/branch'
/** The app-specific results from attempting to rebase a repository */
@ -218,7 +218,7 @@ export async function getRebaseSnapshot(
const percentage = next / last
const value = formatRebaseValue(percentage)
const commits = await getCommitsInRange(
const commits = await getCommitsBetweenCommits(
repository,
baseBranchTip,
originalBranchTip
@ -368,13 +368,19 @@ export async function rebase(
let options = baseOptions
if (progressCallback !== undefined) {
const commits = await getCommitsInRange(
const commits = await getCommitsBetweenCommits(
repository,
baseBranch.tip.sha,
targetBranch.tip.sha
)
if (commits === null) {
// BadRevision can be raised here if git rev-list is unable to resolve a
// ref to a commit ID, so we need to signal to the caller that this rebase
// is not possible to perform
log.warn(
'Unable to rebase these branches because one or both of the refs do not exist in the repository'
)
return RebaseResult.Error
}

View file

@ -7,7 +7,8 @@ import { CommitOneLine } from '../../models/commit'
/**
* Convert two refs into the Git range syntax representing the set of commits
* that are reachable from `to` but excluding those that are reachable from
* `from`.
* `from`. This will not be inclusive to the `from` ref, see
* `revRangeInclusive`.
*
* Each parameter can be the commit SHA or a ref name, or specify an empty
* string to represent HEAD.
@ -19,6 +20,21 @@ export function revRange(from: string, to: string) {
return `${from}..${to}`
}
/**
* Convert two refs into the Git range syntax representing the set of commits
* that are reachable from `to` but excluding those that are reachable from
* `from`. However as opposed to `revRange`, this will also include `from` ref.
*
* Each parameter can be the commit SHA or a ref name, or specify an empty
* string to represent HEAD.
*
* @param from The start of the range
* @param to The end of the range
*/
export function revRangeInclusive(from: string, to: string) {
return `${from}^..${to}`
}
/**
* Convert two refs into the Git symmetric difference syntax, which represents
* the set of commits that are reachable from either `from` or `to` but not
@ -96,6 +112,7 @@ export async function getBranchAheadBehind(
/**
* Get a list of commits from the target branch that do not exist on the base
* branch, ordered how they will be applied to the base branch.
* Therefore, this will not include the baseBranchSha commit.
*
* This emulates how `git rebase` initially determines what will be applied to
* the repository.
@ -103,13 +120,25 @@ export async function getBranchAheadBehind(
* Returns `null` when the rebase is not possible to perform, because of a
* missing commit ID
*/
export async function getCommitsInRange(
export async function getCommitsBetweenCommits(
repository: Repository,
baseBranchSha: string,
targetBranchSha: string
): Promise<ReadonlyArray<CommitOneLine> | null> {
const range = revRange(baseBranchSha, targetBranchSha)
return getCommitsInRange(repository, range)
}
/**
* Get a list of commits inside the provided range.
*
* Returns `null` when it is not possible to perform because of a bad range.
*/
export async function getCommitsInRange(
repository: Repository,
range: string
): Promise<ReadonlyArray<CommitOneLine> | null> {
const args = [
'rev-list',
range,
@ -128,12 +157,6 @@ export async function getCommitsInRange(
const result = await git(args, repository.path, 'getCommitsInRange', options)
if (result.gitError === GitError.BadRevision) {
// BadRevision can be raised here if git rev-list is unable to resolve a ref
// to a commit ID, so we need to signal to the caller that this rebase is
// not possible to perform
log.warn(
'Unable to rebase these branches because one or both of the refs do not exist in the repository'
)
return null
}

View file

@ -12,6 +12,7 @@ import {
} from '../../models/status'
import { parseChangedFiles } from './log'
import { stageFiles } from './update-index'
import { Branch } from '../../models/branch'
export const DesktopStashEntryMarker = '!!GitHub_Desktop'
@ -94,9 +95,10 @@ export async function getStashes(repository: Repository): Promise<StashResult> {
*/
export async function getLastDesktopStashEntryForBranch(
repository: Repository,
branchName: string
branch: Branch | string
) {
const stash = await getStashes(repository)
const branchName = typeof branch === 'string' ? branch : branch.name
// Since stash objects are returned in a LIFO manner, the first
// entry found is guaranteed to be the last entry created
@ -115,9 +117,9 @@ export function createDesktopStashMessage(branchName: string) {
*/
export async function createDesktopStashEntry(
repository: Repository,
branchName: string,
branch: Branch | string,
untrackedFilesToStage: ReadonlyArray<WorkingDirectoryFileChange>
): Promise<true> {
): Promise<boolean> {
// We must ensure that no untracked files are present before stashing
// See https://github.com/desktop/desktop/pull/8085
// First ensure that all changes in file are selected
@ -127,6 +129,7 @@ export async function createDesktopStashEntry(
)
await stageFiles(repository, fullySelectedUntrackedFiles)
const branchName = typeof branch === 'string' ? branch : branch.name
const message = createDesktopStashMessage(branchName)
const args = ['stash', 'push', '-m', message]
@ -153,6 +156,11 @@ export async function createDesktopStashEntry(
)
}
// Stash doesn't consider it an error that there aren't any local changes to save.
if (result.stdout === 'No local changes to save\n') {
return false
}
return true
}

View file

@ -34,5 +34,9 @@ export async function getAuthorIdentity(
return null
}
return CommitIdentity.parseIdentity(result.stdout)
try {
return CommitIdentity.parseIdentity(result.stdout)
} catch (err) {
return null
}
}

View file

@ -1,17 +1,11 @@
import { getGlobalConfigValue, setGlobalConfigValue } from '../git'
import { enableDefaultBranchSetting } from '../feature-flag'
/**
* The default branch name that Desktop's embedded version of Git
* will use when initializing a new repository.
*/
export const DefaultBranchInGit = 'master'
/**
* The default branch name that GitHub Desktop will use when
* initializing a new repository.
*/
export const DefaultBranchInDesktop = 'main'
const DefaultBranchInDesktop = 'main'
/**
* The name of the Git configuration variable which holds what
@ -49,5 +43,5 @@ export async function getDefaultBranch(): Promise<string> {
* @param branchName The default branch name to use.
*/
export async function setDefaultBranch(branchName: string) {
return setGlobalConfigValue('init.defaultBranch', branchName)
return setGlobalConfigValue(DefaultBranchSettingName, branchName)
}

View file

@ -1,28 +1,45 @@
/**
* Send a caught (ie. non-fatal) exception to the
* non-fatal error bucket
* Send a caught (ie. non-fatal) exception to the non-fatal error bucket
*
* The intended use of this message is for getting insight into
* areas of the code where we suspect alternate failure modes
* other than those accounted for.
* The intended use of this message is for getting insight into areas of the
* code where we suspect alternate failure modes other than those accounted for.
*
* Example: In the Desktop tutorial creation logic we handle
* all errors and our initial belief was that the only two failure
* modes we would have to account for were either the repo existing
* on disk or on the user's account. We now suspect that there might
* be other reasons why the creation logic is failing and therefore
* want to send all errors encountered during creation to central
* where we can determine if there are additional failure modes
* for us to consider.
* Example: In the Desktop tutorial creation logic we handle all errors and our
* initial belief was that the only two failure modes we would have to account
* for were either the repo existing on disk or on the user's account. We now
* suspect that there might be other reasons why the creation logic is failing
* and therefore want to send all errors encountered during creation to central
* where we can determine if there are additional failure modes for us to
* consider.
*
* @param kind - a grouping key that allows us to group all errors
* originating in the same area of the code base or relating to the
* same kind of failure (recommend a single non-hyphenated word)
* Example: tutorialRepoCreation
* @param kind - a grouping key that allows us to group all errors originating
* in the same area of the code base or relating to the same kind of failure
* (recommend a single non-hyphenated word) Example: tutorialRepoCreation
*
* @param error - the caught error
*/
import { getHasOptedOutOfStats } from '../stats/stats-store'
let lastNonFatalException: number | undefined = undefined
/** Max one non fatal exeception per minute */
const minIntervalBetweenNonFatalExceptions = 60 * 1000
export function sendNonFatalException(kind: string, error: Error) {
if (getHasOptedOutOfStats()) {
return
}
const now = Date.now()
if (
lastNonFatalException !== undefined &&
now - lastNonFatalException < minIntervalBetweenNonFatalExceptions
) {
return
}
lastNonFatalException = now
process.emit('send-non-fatal-exception', error, { kind })
}

View file

@ -91,7 +91,7 @@ export function getAbsoluteUrl(endpoint: string, path: string): string {
// Our API endpoints are a bit sloppy in that they don't typically
// include the trailing slash (i.e. we use https://api.github.com for
// dotcom and https://ghe.enterprise.local/api/v3 for Enterprise Server when
// dotcom and https://ghe.enterprise.local/api/v3 for Enterprise when
// both of those should really include the trailing slash since that's
// the qualified base). We'll work around our past since here by ensuring
// that the endpoint ends with a trailing slash.

View file

@ -1,3 +1,5 @@
import { parseEnumValue } from './enum'
/**
* Returns the value for the provided key from local storage interpreted as a
* boolean or the provided `defaultValue` if the key doesn't exist.
@ -159,3 +161,19 @@ export function setStringArray(key: string, values: ReadonlyArray<string>) {
/** Default delimiter for stringifying and parsing arrays of numbers */
const NumberArrayDelimiter = ','
/**
* Load a (string) enum based on its stored value. See `parseEnumValue` for more
* details on the conversion. Note that there's no `setEnum` companion method
* here since callers can just use `localStorage.setItem(key, enumValue)`
*
* @param key The localStorage key to read from
* @param enumObj The Enum type definition
*/
export function getEnum<T extends string>(
key: string,
enumObj: Record<string, T>
): T | undefined {
const storedValue = localStorage.getItem(key)
return storedValue === null ? undefined : parseEnumValue(enumObj, storedValue)
}

View file

@ -10,13 +10,13 @@ interface IGitRemoteURL {
* The owner of the GitHub repository. This will be null if the URL doesn't
* take the form of a GitHub repository URL (e.g., owner/name).
*/
readonly owner: string | null
readonly owner: string
/**
* The name of the GitHub repository. This will be null if the URL doesn't
* take the form of a GitHub repository URL (e.g., owner/name).
*/
readonly name: string | null
readonly name: string
}
// Examples:
@ -46,16 +46,9 @@ const remoteRegexes: ReadonlyArray<{ protocol: GitProtocol; regex: RegExp }> = [
/** Parse the remote information from URL. */
export function parseRemote(url: string): IGitRemoteURL | null {
for (const { protocol, regex } of remoteRegexes) {
const result = url.match(regex)
if (!result) {
continue
}
const hostname = result[1]
const owner = result[2]
const name = result[3]
if (hostname) {
return { protocol, hostname, owner, name }
const match = regex.exec(url)
if (match !== null && match.length >= 4) {
return { protocol, hostname: match[1], owner: match[2], name: match[3] }
}
}

View file

@ -23,8 +23,8 @@ export interface IMatchedGitHubRepository {
*/
readonly owner: string
/** The API endpoint. */
readonly endpoint: string
/** The account matching the repository remote */
readonly account: Account
}
/** Try to use the list of users and a remote URL to guess a GitHub repository. */
@ -33,67 +33,36 @@ export function matchGitHubRepository(
remote: string
): IMatchedGitHubRepository | null {
for (const account of accounts) {
const match = matchRemoteWithAccount(account, remote)
if (match) {
return match
const htmlURL = getHTMLURL(account.endpoint)
const { hostname } = URL.parse(htmlURL)
const parsedRemote = parseRemote(remote)
if (parsedRemote !== null && hostname !== null) {
if (parsedRemote.hostname.toLowerCase() === hostname.toLowerCase()) {
return { name: parsedRemote.name, owner: parsedRemote.owner, account }
}
}
}
return null
}
function matchRemoteWithAccount(
account: Account,
remote: string
): IMatchedGitHubRepository | null {
const htmlURL = getHTMLURL(account.endpoint)
const parsed = URL.parse(htmlURL)
const host = parsed.hostname
const parsedRemote = parseRemote(remote)
if (!parsedRemote) {
return null
}
const owner = parsedRemote.owner
const name = parsedRemote.name
if (
host &&
parsedRemote.hostname.toLowerCase() === host.toLowerCase() &&
owner &&
name
) {
return { name, owner, endpoint: account.endpoint }
}
return null
}
/**
* Find an existing repository associated with this path
*
* @param repositories The list of repositories tracked in the app
* @param repos The list of repositories tracked in the app
* @param path The path on disk which might be a repository
*/
export function matchExistingRepository(
repositories: ReadonlyArray<Repository | CloningRepository>,
path: string
): Repository | CloningRepository | null {
return (
repositories.find(r => {
if (__WIN32__) {
// Windows is guaranteed to be case-insensitive so we can be a
// bit more accepting.
return (
Path.normalize(r.path).toLowerCase() ===
Path.normalize(path).toLowerCase()
)
} else {
return Path.normalize(r.path) === Path.normalize(path)
}
}) || null
)
export function matchExistingRepository<
T extends Repository | CloningRepository
>(repos: ReadonlyArray<T>, path: string): T | undefined {
// Windows is guaranteed to be case-insensitive so we can be a bit less strict
const normalize = __WIN32__
? (p: string) => Path.normalize(p).toLowerCase()
: (p: string) => Path.normalize(p)
const needle = normalize(path)
return repos.find(r => normalize(r.path) === needle)
}
/**

View file

@ -2,6 +2,7 @@ import { spawn, ChildProcess } from 'child_process'
import { assertNever } from '../fatal-error'
import { IFoundShell } from './found-shell'
import appPath from 'app-path'
import { parseEnumValue } from '../enum'
export enum Shell {
Terminal = 'Terminal',
@ -15,31 +16,7 @@ export enum Shell {
export const Default = Shell.Terminal
export function parse(label: string): Shell {
if (label === Shell.Terminal) {
return Shell.Terminal
}
if (label === Shell.Hyper) {
return Shell.Hyper
}
if (label === Shell.iTerm2) {
return Shell.iTerm2
}
if (label === Shell.PowerShellCore) {
return Shell.PowerShellCore
}
if (label === Shell.Kitty) {
return Shell.Kitty
}
if (label === Shell.Alacritty) {
return Shell.Alacritty
}
return Default
return parseEnumValue(Shell, label) ?? Default
}
function getBundleID(shell: Shell): string {

View file

@ -2,6 +2,7 @@ import { spawn, ChildProcess } from 'child_process'
import { pathExists } from 'fs-extra'
import { assertNever } from '../fatal-error'
import { IFoundShell } from './found-shell'
import { parseEnumValue } from '../enum'
export enum Shell {
Gnome = 'GNOME Terminal',
@ -17,39 +18,7 @@ export enum Shell {
export const Default = Shell.Gnome
export function parse(label: string): Shell {
if (label === Shell.Gnome) {
return Shell.Gnome
}
if (label === Shell.Mate) {
return Shell.Mate
}
if (label === Shell.Tilix) {
return Shell.Tilix
}
if (label === Shell.Terminator) {
return Shell.Terminator
}
if (label === Shell.Urxvt) {
return Shell.Urxvt
}
if (label === Shell.Konsole) {
return Shell.Konsole
}
if (label === Shell.Xterm) {
return Shell.Xterm
}
if (label === Shell.Terminology) {
return Shell.Terminology
}
return Default
return parseEnumValue(Shell, label) ?? Default
}
async function getPathIfAvailable(path: string): Promise<string | null> {

View file

@ -7,6 +7,7 @@ import { assertNever } from '../fatal-error'
import { IFoundShell } from './found-shell'
import { enableWSLDetection } from '../feature-flag'
import { findGitOnPath } from '../is-git-on-path'
import { parseEnumValue } from '../enum'
export enum Shell {
Cmd = 'Command Prompt',
@ -23,43 +24,7 @@ export enum Shell {
export const Default = Shell.Cmd
export function parse(label: string): Shell {
if (label === Shell.Cmd) {
return Shell.Cmd
}
if (label === Shell.PowerShell) {
return Shell.PowerShell
}
if (label === Shell.PowerShellCore) {
return Shell.PowerShellCore
}
if (label === Shell.Hyper) {
return Shell.Hyper
}
if (label === Shell.GitBash) {
return Shell.GitBash
}
if (label === Shell.Cygwin) {
return Shell.Cygwin
}
if (label === Shell.WSL) {
return Shell.WSL
}
if (label === Shell.WindowTerminal) {
return Shell.WindowTerminal
}
if (label === Shell.Alacritty) {
return Shell.Alacritty
}
return Default
return parseEnumValue(Shell, label) ?? Default
}
export async function getAvailableShells(): Promise<
@ -404,31 +369,18 @@ export function launch(
switch (shell) {
case Shell.PowerShell:
const psCommand = `"Set-Location -LiteralPath '${path}'"`
return spawn(
'START',
[
'"PowerShell"',
`"${foundShell.path}"`,
'-NoExit',
'-Command',
psCommand,
],
{
shell: true,
cwd: path,
}
)
return spawn('START', ['"PowerShell"', `"${foundShell.path}"`], {
shell: true,
cwd: path,
})
case Shell.PowerShellCore:
const psCoreCommand = `"Set-Location -LiteralPath '${path}'"`
return spawn(
'START',
[
'"PowerShell Core"',
`"${foundShell.path}"`,
'-NoExit',
'-Command',
psCoreCommand,
'-WorkingDirectory',
`"${path}"`,
],
{
shell: true,

View file

@ -65,34 +65,16 @@ export interface IDailyMeasures {
/** The numbers of times a repo without indicators is clicked on repo list view */
readonly repoWithoutIndicatorClicked: number
/** The number of times the user dismisses the diverged branch notification */
readonly divergingBranchBannerDismissal: number
/** The number of times the user merges from the diverged branch notification merge CTA button */
readonly divergingBranchBannerInitatedMerge: number
/** The number of times the user compares from the diverged branch notification compare CTA button */
readonly divergingBranchBannerInitiatedCompare: number
/**
* The number of times the user merges from the compare view after getting to that state
* from the diverged branch notification compare CTA button
*/
readonly divergingBranchBannerInfluencedMerge: number
/** The number of times the diverged branch notification is displayed */
readonly divergingBranchBannerDisplayed: number
/** The number of times the user pushes to GitHub.com */
readonly dotcomPushCount: number
/** The number of times the user pushes with `--force-with-lease` to GitHub.com */
readonly dotcomForcePushCount: number
/** The number of times the user pushed to a GitHub Enterprise Server instance */
/** The number of times the user pushed to a GitHub Enterprise instance */
readonly enterprisePushCount: number
/** The number of times the user pushes with `--force-with-lease` to a GitHub Enterprise Server instance */
/** The number of times the user pushes with `--force-with-lease` to a GitHub Enterprise instance */
readonly enterpriseForcePushCount: number
/** The number of times the users pushes to a generic remote */
@ -130,14 +112,14 @@ export interface IDailyMeasures {
/**
* The number of times the user made a commit to a repo hosted on
* a GitHub Enterprise Server instance
* a GitHub Enterprise instance
*/
readonly enterpriseCommits: number
/** The number of times the user made a commit to a repo hosted on Github.com */
readonly dotcomCommits: number
/** The number of times the user made a commit to a protected GitHub or GitHub Enterprise Server repository */
/** The number of times the user made a commit to a protected GitHub or GitHub Enterprise repository */
readonly commitsToProtectedBranch: number
/** The number of times the user made a commit to a repository with branch protections enabled */
@ -370,6 +352,9 @@ export interface IDailyMeasures {
/** Number of times the user has switched to or from History/Changes */
readonly repositoryViewChangeCount: number
/** Number of times the user has encountered an unhandled rejection */
readonly unhandledRejectionCount: number
}
export class StatsDatabase extends Dexie {

View file

@ -22,6 +22,7 @@ import {
} from '../local-storage'
import { PushOptions } from '../git'
import { getShowSideBySideDiff } from '../../ui/lib/diff-mode'
import { remote } from 'electron'
const StatsEndpoint = 'https://central.github.com/api/usage/desktop'
@ -68,11 +69,6 @@ const DefaultDailyMeasures: IDailyMeasures = {
prBranchCheckouts: 0,
repoWithIndicatorClicked: 0,
repoWithoutIndicatorClicked: 0,
divergingBranchBannerDismissal: 0,
divergingBranchBannerInitatedMerge: 0,
divergingBranchBannerInitiatedCompare: 0,
divergingBranchBannerInfluencedMerge: 0,
divergingBranchBannerDisplayed: 0,
dotcomPushCount: 0,
dotcomForcePushCount: 0,
enterprisePushCount: 0,
@ -143,6 +139,7 @@ const DefaultDailyMeasures: IDailyMeasures = {
diffModeChangeCount: 0,
diffOptionsViewedCount: 0,
repositoryViewChangeCount: 0,
unhandledRejectionCount: 0,
}
interface IOnboardingStats {
@ -202,7 +199,7 @@ interface IOnboardingStats {
* Time (in seconds) from when the user first launched
* the application and entered the welcome wizard until
* the user performed their first push of a repository
* to GitHub.com or GitHub Enterprise Server. This metric
* to GitHub.com or GitHub Enterprise. This metric
* does not track pushes to non-GitHub remotes.
*/
readonly timeToFirstGitHubPush?: number
@ -284,7 +281,7 @@ interface ICalculatedStats {
/** Is the user logged in with a GitHub.com account? */
readonly dotComAccount: boolean
/** Is the user logged in with an Enterprise Server account? */
/** Is the user logged in with an Enterprise account? */
readonly enterpriseAccount: boolean
/**
@ -316,6 +313,12 @@ interface ICalculatedStats {
* default) diff view mode
*/
readonly diffMode: 'split' | 'unified'
/**
* Whether the app was launched from the Applications folder or not. This is
* only relevant on macOS, null will be sent otherwise.
*/
readonly launchedFromApplicationsFolder: boolean | null
}
type DailyStats = ICalculatedStats &
@ -350,7 +353,7 @@ export class StatsStore implements IStatsStore {
this.db = db
this.uiActivityMonitor = uiActivityMonitor
const storedValue = getBoolean(StatsOptOutKey)
const storedValue = getHasOptedOutOfStats()
this.optOut = storedValue || false
@ -361,6 +364,14 @@ export class StatsStore implements IStatsStore {
}
this.enableUiActivityMonitoring()
window.addEventListener('unhandledrejection', async () => {
try {
this.recordUnhandledRejection()
} catch (err) {
log.error(`Failed recording unhandled rejection`, err)
}
})
}
/** Should the app report its daily stats? */
@ -477,6 +488,10 @@ export class StatsStore implements IStatsStore {
).length
const diffMode = getShowSideBySideDiff() ? 'split' : 'unified'
// isInApplicationsFolder is undefined when not running on Darwin
const launchedFromApplicationsFolder =
remote.app.isInApplicationsFolder?.() ?? null
return {
eventType: 'usage',
version: getVersion(),
@ -493,6 +508,7 @@ export class StatsStore implements IStatsStore {
...repositoryCounts,
repositoriesCommittedInWithoutWriteAccess,
diffMode,
launchedFromApplicationsFolder,
}
}
@ -726,7 +742,7 @@ export class StatsStore implements IStatsStore {
/**
* Records that the user made a commit using an email address that
* was not associated with the user's account on GitHub.com or GitHub
* Enterprise Server, meaning that the commit will not be attributed to the
* Enterprise, meaning that the commit will not be attributed to the
* user's account.
*/
public recordUnattributedCommit(): Promise<void> {
@ -737,7 +753,7 @@ export class StatsStore implements IStatsStore {
/**
* Records that the user made a commit to a repository hosted on
* a GitHub Enterprise Server instance
* a GitHub Enterprise instance
*/
public recordCommitToEnterprise(): Promise<void> {
return this.updateDailyMeasures(m => ({
@ -752,7 +768,7 @@ export class StatsStore implements IStatsStore {
}))
}
/** Record the user made a commit to a protected GitHub or GitHub Enterprise Server repository */
/** Record the user made a commit to a protected GitHub or GitHub Enterprise repository */
public recordCommitToProtectedBranch(): Promise<void> {
return this.updateDailyMeasures(m => ({
commitsToProtectedBranch: m.commitsToProtectedBranch + 1,
@ -790,47 +806,6 @@ export class StatsStore implements IStatsStore {
return this.optOut
}
/** Record that user dismissed diverging branch notification */
public recordDivergingBranchBannerDismissal(): Promise<void> {
return this.updateDailyMeasures(m => ({
divergingBranchBannerDismissal: m.divergingBranchBannerDismissal + 1,
}))
}
/** Record that user initiated a merge from within the notification banner */
public recordDivergingBranchBannerInitatedMerge(): Promise<void> {
return this.updateDailyMeasures(m => ({
divergingBranchBannerInitatedMerge:
m.divergingBranchBannerInitatedMerge + 1,
}))
}
/** Record that user initiated a compare from within the notification banner */
public recordDivergingBranchBannerInitiatedCompare(): Promise<void> {
return this.updateDailyMeasures(m => ({
divergingBranchBannerInitiatedCompare:
m.divergingBranchBannerInitiatedCompare + 1,
}))
}
/**
* Record that user initiated a merge after getting to compare view
* from within notification banner
*/
public recordDivergingBranchBannerInfluencedMerge(): Promise<void> {
return this.updateDailyMeasures(m => ({
divergingBranchBannerInfluencedMerge:
m.divergingBranchBannerInfluencedMerge + 1,
}))
}
/** Record that the user was shown the notification banner */
public recordDivergingBranchBannerDisplayed(): Promise<void> {
return this.updateDailyMeasures(m => ({
divergingBranchBannerDisplayed: m.divergingBranchBannerDisplayed + 1,
}))
}
public async recordPush(
githubAccount: Account | null,
options?: PushOptions
@ -859,7 +834,7 @@ export class StatsStore implements IStatsStore {
createLocalStorageTimestamp(FirstPushToGitHubAtKey)
}
/** Record that the user pushed to a GitHub Enterprise Server instance */
/** Record that the user pushed to a GitHub Enterprise instance */
private async recordPushToGitHubEnterprise(
options?: PushOptions
): Promise<void> {
@ -1406,6 +1381,12 @@ export class StatsStore implements IStatsStore {
}))
}
public recordUnhandledRejection() {
return this.updateDailyMeasures(m => ({
unhandledRejectionCount: m.unhandledRejectionCount + 1,
}))
}
/** Post some data to our stats endpoint. */
private post(body: object): Promise<Response> {
const options: RequestInit = {
@ -1467,11 +1448,9 @@ export class StatsStore implements IStatsStore {
* overwritten.
*/
function createLocalStorageTimestamp(key: string) {
if (localStorage.getItem(key) !== null) {
return
if (localStorage.getItem(key) === null) {
setNumber(key, Date.now())
}
setNumber(key, Date.now())
}
/**
@ -1537,3 +1516,11 @@ function getWelcomeWizardSignInMethod(): 'basic' | 'web' | undefined {
return undefined
}
}
/**
* Return a value indicating whether the user has opted out of stats reporting
* or not.
*/
export function getHasOptedOutOfStats() {
return getBoolean(StatsOptOutKey)
}

View file

@ -0,0 +1,134 @@
import pLimit from 'p-limit'
import QuickLRU from 'quick-lru'
import { IDisposable, Disposable } from 'event-kit'
import { IAheadBehind } from '../../models/branch'
import { revSymmetricDifference, getAheadBehind } from '../git'
import { Repository } from '../../models/repository'
export type AheadBehindCallback = (aheadBehind: IAheadBehind) => void
/** Creates a cache key for a particular commit range in a specific repository */
function getCacheKey(repository: Repository, from: string, to: string) {
return `${repository.path}:${from}:${to}`
}
/**
* The maximum number of _concurrent_ `git rev-list` operations we'll run. We're
* gonna play it safe and stick to no concurrent operations initially since
* that's how the previous ahead/behind logic worked but it should be safe to
* bump this to 3 or so to squeeze some more performance out of it.
*/
const MaxConcurrent = 1
export class AheadBehindStore {
/**
* A map keyed on the value of `getCacheKey` containing one object per
* reference (repository specific) with the last retrieved ahead behind status
* for that reference.
*
* This map also functions as a least recently used cache and will evict the
* least recently used comparisons to ensure the cache won't grow unbounded
*/
private readonly cache = new QuickLRU<string, IAheadBehind | null>({
maxSize: 2500,
})
/** Currently executing workers. Contains at most `MaxConcurrent` workers */
private readonly workers = new Map<string, Promise<IAheadBehind | null>>()
/**
* A concurrency limiter which ensures that we only run `MaxConcurrent`
* ahead/behind calculations concurrently
*/
private readonly limit = pLimit(MaxConcurrent)
/**
* Attempt to _synchronously_ retrieve an ahead behind status for a particular
* range. If the range doesn't exist in the cache this function returns
* undefined.
*
* Useful for component who wish to have a value for the initial render
* instead of waiting for the subscription to produce an event.
*
* Note that while it's technically possible to use refs or revision
* expressions instead of commit ids here it's strongly recommended against as
* the store has no way of knowing when these refs are updated. Using oids
* means we can rely on the ids themselves for invalidation.
*/
public tryGetAheadBehind(repository: Repository, from: string, to: string) {
return this.cache.get(getCacheKey(repository, from, to)) ?? undefined
}
/**
* Subscribe to the result of calculating the ahead behind status for the
* given range. The operation can be aborted using the returned Disposable.
*
* Aborting means that the callback won't execute and if that we'll try to
* avoid invoking Git unless we've already done so or there's another caller
* requesting that calculation. Aborting after the callback has been invoked
* is a no-op.
*
* The callback will not fire if we were unsuccessful in calculating the
* ahead/behind status.
*/
public getAheadBehind(
repository: Repository,
from: string,
to: string,
callback: AheadBehindCallback
): IDisposable {
const key = getCacheKey(repository, from, to)
const existing = this.cache.get(key)
const disposable = new Disposable(() => {})
// We failed loading on the last attempt in which case we won't retry
if (existing === null) {
return disposable
}
if (existing !== undefined) {
callback(existing)
return disposable
}
this.limit(async () => {
const existing = this.cache.get(key)
// The caller has either aborted or we've previously failed loading ahead/
// behind status for this ref pair. We don't retry previously failed ops
if (disposable.disposed || existing === null) {
return
}
if (existing !== undefined) {
callback(existing)
return
}
let worker = this.workers.get(key)
if (worker === undefined) {
worker = getAheadBehind(repository, revSymmetricDifference(from, to))
.catch(e => {
log.error('Failed calculating ahead/behind status', e)
return null
})
.then(aheadBehind => {
this.cache.set(key, aheadBehind)
return aheadBehind
})
.finally(() => this.workers.delete(key))
this.workers.set(key, worker)
}
const aheadBehind = await worker
if (aheadBehind !== null && !disposable.disposed) {
callback(aheadBehind)
}
}).catch(e => log.error('Failed calculating ahead/behind status', e))
return disposable
}
}

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,7 @@ import {
} from '../../models/branch'
import { Tip, TipState } from '../../models/tip'
import { Commit } from '../../models/commit'
import { IRemote, ForkedRemotePrefix } from '../../models/remote'
import { IRemote } from '../../models/remote'
import { IFetchProgress, IRevertProgress } from '../../models/progress'
import {
ICommitMessage,
@ -66,6 +66,7 @@ import {
getAllTags,
deleteTag,
MergeResult,
createBranch,
} from '../git'
import { GitError as DugiteError } from '../../lib/git'
import { GitError } from 'dugite'
@ -90,6 +91,7 @@ import { getTagsToPush, storeTagsToPush } from './helpers/tags-to-push-storage'
import { DiffSelection, ITextDiff } from '../../models/diff'
import { getDefaultBranch } from '../helpers/default-branch'
import { stat } from 'fs-extra'
import { findForkedRemotesToPrune } from './helpers/find-forked-remotes-to-prune'
/** The number of commits to load from history per batch. */
const CommitBatchSize = 100
@ -334,6 +336,26 @@ export class GitStore extends BaseStore {
this.storeCommits(commitsToStore)
}
public async createBranch(
name: string,
startPoint: string | null,
noTrackOption: boolean = false
) {
const createdBranch = await this.performFailableOperation(async () => {
await createBranch(this.repository, name, startPoint, noTrackOption)
return true
})
if (createdBranch === true) {
await this.loadBranches()
return this.allBranches.find(
x => x.type === BranchType.Local && x.name === name
)
}
return undefined
}
public async createTag(name: string, targetCommitSha: string) {
const result = await this.performFailableOperation(async () => {
await createTag(this.repository, name, targetCommitSha)
@ -548,10 +570,15 @@ export class GitStore extends BaseStore {
return
}
const branchesByName = this._allBranches.reduce(
(map, branch) => map.set(branch.name, branch),
new Map<string, Branch>()
)
const branchesByName = new Map<string, Branch>()
for (const branch of this._allBranches) {
// This is slightly redundant as remote branches should never show up as
// having been checked out in the reflog but it makes the intention clear.
if (branch.type === BranchType.Local) {
branchesByName.set(branch.name, branch)
}
}
const recentBranches = new Array<Branch>()
for (const name of recentBranchNames) {
@ -959,7 +986,10 @@ export class GitStore extends BaseStore {
// any new commits available
if (this.tip.kind === TipState.Valid) {
const currentBranch = this.tip.branch
if (currentBranch.remote !== null && currentBranch.upstream !== null) {
if (
currentBranch.upstreamRemoteName !== null &&
currentBranch.upstream !== null
) {
const range = revSymmetricDifference(
currentBranch.name,
currentBranch.upstream
@ -1080,7 +1110,8 @@ export class GitStore extends BaseStore {
currentBranch,
status.currentUpstreamBranch || null,
branchTipCommit,
BranchType.Local
BranchType.Local,
`refs/heads/${currentBranch}`
)
this._tip = { kind: TipState.Valid, branch }
} else if (currentTip) {
@ -1219,8 +1250,9 @@ export class GitStore extends BaseStore {
this._defaultRemote = findDefaultRemote(remotes)
const currentRemoteName =
this.tip.kind === TipState.Valid && this.tip.branch.remote !== null
? this.tip.branch.remote
this.tip.kind === TipState.Valid &&
this.tip.branch.upstreamRemoteName !== null
? this.tip.branch.upstreamRemoteName
: null
// Load the remote that the current branch is tracking. If the branch
@ -1621,18 +1653,12 @@ export class GitStore extends BaseStore {
public async pruneForkedRemotes(openPRs: ReadonlyArray<PullRequest>) {
const remotes = await getRemotes(this.repository)
const prRemotes = new Set<string>()
for (const pr of openPRs) {
if (pr.head.gitHubRepository.cloneURL !== null) {
prRemotes.add(pr.head.gitHubRepository.cloneURL)
}
}
const branches = this.allBranches
const remotesToPrune = findForkedRemotesToPrune(remotes, openPRs, branches)
for (const r of remotes) {
if (r.name.startsWith(ForkedRemotePrefix) && !prRemotes.has(r.url)) {
await removeRemote(this.repository, r.name)
}
for (const remote of remotesToPrune) {
await removeRemote(this.repository, remote.name)
}
}
}

View file

@ -74,8 +74,6 @@ export class GitHubUserStore extends BaseStore {
repository: GitHubRepository,
account: Account
): Promise<void> {
assertPersisted(repository)
const api = API.fromAccount(account)
const cacheEntry = await this.database.getMentionableCacheEntry(
@ -127,7 +125,6 @@ export class GitHubUserStore extends BaseStore {
public async getMentionableUsers(
repository: GitHubRepository
): Promise<ReadonlyArray<IMentionableUser>> {
assertPersisted(repository)
return this.database.getAllMentionablesForRepository(repository.dbID)
}
@ -152,8 +149,6 @@ export class GitHubUserStore extends BaseStore {
query: string,
maxHits: number = DefaultMaxHits
): Promise<ReadonlyArray<IMentionableUser>> {
assertPersisted(repository)
const users =
this.queryCache?.repository.dbID === repository.dbID
? this.queryCache.users
@ -208,13 +203,3 @@ export class GitHubUserStore extends BaseStore {
}
}
}
function assertPersisted(
repo: GitHubRepository
): asserts repo is GitHubRepository & { dbID: number } {
if (repo.dbID === null) {
throw new Error(
`Need a GitHubRepository that's been inserted into the database`
)
}
}

View file

@ -1,132 +0,0 @@
import queue from 'queue'
import { revSymmetricDifference } from '../../../lib/git'
import { Repository } from '../../../models/repository'
import { getAheadBehind } from '../../../lib/git'
import { Branch, IAheadBehind } from '../../../models/branch'
import { ComparisonCache } from '../../comparison-cache'
export class AheadBehindUpdater {
private comparisonCache = new ComparisonCache()
private aheadBehindQueue = queue({
concurrency: 1,
autostart: true,
})
public constructor(
private repository: Repository,
private onCacheUpdate: (cache: ComparisonCache) => void
) {}
public start() {
this.aheadBehindQueue.on('success', (result: IAheadBehind | null) => {
if (result != null) {
this.onCacheUpdate(this.comparisonCache)
}
})
this.aheadBehindQueue.on('error', (err: Error) => {
log.debug(
'[AheadBehindUpdater] an error with the queue was reported',
err
)
})
this.aheadBehindQueue.on('end', (err?: Error) => {
if (err != null) {
log.debug(`[AheadBehindUpdater] ended with an error`, err)
}
})
this.aheadBehindQueue.start()
}
public stop() {
this.aheadBehindQueue.end()
}
public async executeAsyncTask(
from: string,
to: string
): Promise<IAheadBehind | null> {
if (this.comparisonCache.has(from, to)) {
return this.comparisonCache.get(from, to)
}
const range = revSymmetricDifference(from, to)
const result = await getAheadBehind(this.repository, range)
if (result !== null) {
this.comparisonCache.set(from, to, result)
} else {
log.debug(
`[AheadBehindUpdater] unable to cache '${range}' as no result returned`
)
}
return result
}
public insert(from: string, to: string, value: IAheadBehind) {
if (this.comparisonCache.has(from, to)) {
return
}
this.comparisonCache.set(from, to, value)
}
/**
* Stop processing any ahead/behind computations for the current repository
*/
public clear() {
this.aheadBehindQueue.end()
}
/**
* Schedule ahead/behind computations for all available branches in
* the current repository, where they haven't been already computed
*
* @param currentBranch The current branch of the repository
* @param defaultBranch The default branch (if defined)
* @param recentBranches Recent branches in the repository
* @param allBranches All known branches in the repository
*/
public schedule(
currentBranch: Branch,
defaultBranch: Branch | null,
recentBranches: ReadonlyArray<Branch>,
allBranches: ReadonlyArray<Branch>
) {
this.clear()
const from = currentBranch.tip.sha
const filterBranchesNotInCache = (branches: ReadonlyArray<Branch>) => {
return branches
.map(b => b.tip.sha)
.filter(to => !this.comparisonCache.has(from, to))
}
const otherBranches = [...recentBranches, ...allBranches]
const branches =
defaultBranch !== null ? [defaultBranch, ...otherBranches] : otherBranches
const newRefsToCompare = new Set<string>(filterBranchesNotInCache(branches))
log.debug(
`[AheadBehindUpdater] - found ${newRefsToCompare.size} comparisons to perform`
)
for (const sha of newRefsToCompare) {
this.aheadBehindQueue.push(
() =>
new Promise<IAheadBehind | null>((resolve, reject) => {
requestIdleCallback(() => {
this.executeAsyncTask(from, sha).then(resolve, reject)
})
})
)
}
}
}

View file

@ -1,4 +1,7 @@
import { Repository } from '../../../models/repository'
import {
Repository,
isRepositoryWithGitHubRepository,
} from '../../../models/repository'
import { RepositoriesStore } from '../repositories-store'
import { Branch } from '../../../models/branch'
import { GitStoreCache } from '../git-store-cache'
@ -7,8 +10,8 @@ import {
getBranchCheckouts,
getSymbolicRef,
formatAsLocalRef,
deleteLocalBranch,
getBranches,
deleteLocalBranch,
} from '../../git'
import { fatalError } from '../../fatal-error'
import { RepositoryStateCache } from '../repository-state-cache'
@ -127,8 +130,7 @@ export class BranchPruner {
private async pruneLocalBranches(
options: PruneRuntimeOptions
): Promise<void> {
const { gitHubRepository } = this.repository
if (gitHubRepository === null) {
if (!isRepositoryWithGitHubRepository(this.repository)) {
return
}

View file

@ -12,10 +12,7 @@ import { git } from '../../git'
import { friendlyEndpointName } from '../../friendly-endpoint-name'
import { IRemote } from '../../../models/remote'
import { envForRemoteOperation } from '../../git/environment'
import {
DefaultBranchInGit,
DefaultBranchInDesktop,
} from '../../helpers/default-branch'
import { getDefaultBranch } from '../../helpers/default-branch'
const nl = __WIN32__ ? '\r\n' : '\n'
const InitialReadmeContents =
@ -118,15 +115,16 @@ export async function createTutorialRepository(
}
const repo = await createAPIRepository(account, name)
const branch = repo.default_branch ?? DefaultBranchInDesktop
const branch = repo.default_branch ?? (await getDefaultBranch())
progressCb('Initializing local repository', 0.2)
await ensureDir(path)
await git(['init'], path, 'tutorial:init')
if (branch !== DefaultBranchInGit) {
await git(['checkout', '-b', branch], path, 'tutorial:rename-branch')
}
await git(
['-c', `init.defaultBranch=${branch}`, 'init'],
path,
'tutorial:init'
)
await writeFile(Path.join(path, 'README.md'), InitialReadmeContents)

View file

@ -1,46 +0,0 @@
import { IBranchesState } from '../../app-state'
import { eligibleForFastForward, Branch } from '../../../models/branch'
import { TipState } from '../../../models/tip'
/**
* As fast-forwarding local branches is proportional to the number of local
* branches, and is run after every fetch/push/pull, this is skipped when the
* number of eligible branches is greater than a given threshold.
*/
const FastForwardBranchesThreshold = 20
/** Figured out what branches are eligible to fast forward
*
* If all eligible branches count is more than `FastForwardBranchesThreshold`,
* returns a shorter list of default and recent branches
*
* @param branchesState current branchesState for a repository
* @returns list of branches eligible for fast forward
*/
export function findBranchesForFastForward(
branchesState: IBranchesState
): ReadonlyArray<Branch> {
const { allBranches, tip, defaultBranch, recentBranches } = branchesState
const currentBranchName = tip.kind === TipState.Valid ? tip.branch.name : null
const allEligibleBranches = allBranches.filter(b =>
eligibleForFastForward(b, currentBranchName)
)
if (allEligibleBranches.length < FastForwardBranchesThreshold) {
return allEligibleBranches
}
log.info(
`skipping fast-forward for all branches as there are ${allEligibleBranches.length} eligible branches (Threshold is ${FastForwardBranchesThreshold} eligible branches).`
)
// we don't have to worry about this being a duplicate, because recent branches
// never include the default branch (at least right now)
const shortListBranches =
defaultBranch !== null ? [...recentBranches, defaultBranch] : recentBranches
const eligibleShortListBranches = shortListBranches.filter(b =>
eligibleForFastForward(b, currentBranchName)
)
return eligibleShortListBranches
}

View file

@ -0,0 +1,32 @@
import { Branch } from '../../../models/branch'
import { PullRequest } from '../../../models/pull-request'
import { ForkedRemotePrefix, IRemote } from '../../../models/remote'
/**
* Function to determine which of the fork remotes added by the app are not
* referenced anymore (by pull requests or local branches) and can be removed
* from a repository.
*
* @param remotes All remotes available in the repository.
* @param openPRs All open pull requests available in the repository.
* @param allBranches All branches available in the repository.
*/
export function findForkedRemotesToPrune(
remotes: readonly IRemote[],
openPRs: ReadonlyArray<PullRequest>,
allBranches: readonly Branch[]
) {
const prRemoteUrls = new Set(
openPRs.map(pr => pr.head.gitHubRepository.cloneURL)
)
const branchRemotes = new Set(
allBranches.map(branch => branch.upstreamRemoteName)
)
return remotes.filter(
r =>
r.name.startsWith(ForkedRemotePrefix) &&
!prRemoteUrls.has(r.url) &&
!branchRemotes.has(r.name)
)
}

View file

@ -1,111 +0,0 @@
import { Branch } from '../../../models/branch'
import { PullRequest } from '../../../models/pull-request'
import { GitHubRepository } from '../../../models/github-repository'
import { IRemote } from '../../../models/remote'
import {
Repository,
isRepositoryWithGitHubRepository,
RepositoryWithGitHubRepository,
getNonForkGitHubRepository,
} from '../../../models/repository'
import { urlMatchesCloneURL } from '../../repository-matching'
type RemotesGetter = (repository: Repository) => Promise<ReadonlyArray<IRemote>>
/**
* Infers which branch to use as the comparison branch
*
* The branch returned is determined by the following conditions:
* 1. Given a pull request -> target branch of PR
* 2. Given a forked repository -> default branch on `upstream`
* 3. Given a hosted repository -> default branch on `origin`
* 4. Fallback -> default branch
*
* @param repository The repository the branch belongs to
* @param branches The list of all branches for the repository
* @param currentPullRequest The pull request to use for finding the branch
* @param getRemotes callback used to get all remotes for the current repository
* @param defaultBranch the current default branch or null if default branch is not known
*/
export async function inferComparisonBranch(
repository: Repository,
branches: ReadonlyArray<Branch>,
currentPullRequest: PullRequest | null,
getRemotes: RemotesGetter,
defaultBranch: Branch | null
): Promise<Branch | null> {
if (currentPullRequest !== null) {
const prBranch = getTargetBranchOfPullRequest(branches, currentPullRequest)
if (prBranch !== null) {
return prBranch
}
}
if (isRepositoryWithGitHubRepository(repository)) {
if (repository.gitHubRepository.fork) {
const upstreamBranch = await getDefaultBranchOfForkedGitHubRepo(
repository,
branches,
getRemotes
)
if (upstreamBranch !== null) {
return upstreamBranch
}
}
const originBranch = getDefaultBranchOfGitHubRepo(
branches,
repository.gitHubRepository
)
if (originBranch !== null) {
return originBranch
}
}
return defaultBranch
}
function getDefaultBranchOfGitHubRepo(
branches: ReadonlyArray<Branch>,
ghRepository: GitHubRepository
): Branch | null {
if (ghRepository.defaultBranch === null) {
return null
}
return findBranch(branches, ghRepository.defaultBranch)
}
function getTargetBranchOfPullRequest(
branches: ReadonlyArray<Branch>,
pr: PullRequest
): Branch | null {
return findBranch(branches, pr.base.ref)
}
async function getDefaultBranchOfForkedGitHubRepo(
repository: RepositoryWithGitHubRepository,
branches: ReadonlyArray<Branch>,
getRemotes: RemotesGetter
): Promise<Branch | null> {
const repoToUse = getNonForkGitHubRepository(repository)
const remotes = await getRemotes(repository)
const remote = remotes.find(r => urlMatchesCloneURL(r.url, repoToUse))
if (remote === undefined) {
log.warn(`Could not find remote with URL ${repoToUse.cloneURL}.`)
return null
}
const branchToFind = `${remote.name}/${repoToUse.defaultBranch}`
return findBranch(branches, branchToFind)
}
function findBranch(
branches: ReadonlyArray<Branch>,
name: string
): Branch | null {
return branches.find(b => b.name === name) || null
}

View file

@ -1,7 +1,6 @@
import { IRepositoryState } from '../../app-state'
import { TutorialStep } from '../../../models/tutorial-step'
import { TipState } from '../../../models/tip'
import { ExternalEditor } from '../../editors'
import { setBoolean, getBoolean } from '../../local-storage'
const skipInstallEditorKey = 'tutorial-install-editor-skipped'
@ -32,7 +31,7 @@ export class OnboardingTutorialAssessor {
public constructor(
/** Method to call when we need to get the current editor */
private getResolvedExternalEditor: () => ExternalEditor | null
private getResolvedExternalEditor: () => string | null
) {}
/** Determines what step the user needs to complete next in the Onboarding Tutorial */

View file

@ -44,8 +44,6 @@ export class IssuesStore {
private async getLatestUpdatedAt(
repository: GitHubRepository
): Promise<Date | null> {
assertPersisted(repository)
const db = this.db
const latestUpdatedIssue = await db.issues
@ -94,8 +92,6 @@ export class IssuesStore {
issues: ReadonlyArray<IAPIIssue>,
repository: GitHubRepository
): Promise<void> {
assertPersisted(repository)
const issuesToDelete = issues.filter(i => i.state === 'closed')
const issuesToUpsert = issues
.filter(i => i.state === 'open')
@ -152,8 +148,6 @@ export class IssuesStore {
}
private async getAllIssueHitsFor(repository: GitHubRepository) {
assertPersisted(repository)
const hits = await this.db.getIssuesForRepository(repository.dbID)
return hits.map(i => ({ number: i.number, title: i.title }))
}
@ -164,11 +158,10 @@ export class IssuesStore {
text: string,
maxHits = DefaultMaxHits
): Promise<ReadonlyArray<IIssueHit>> {
assertPersisted(repository)
const issues =
this.queryCache?.repository.dbID === repository.dbID
? this.queryCache?.issues
? // Dexie gets confused if we return without wrapping in promise
await Promise.resolve(this.queryCache?.issues)
: await this.getAllIssueHitsFor(repository)
this.setQueryCache(repository, issues)
@ -221,13 +214,3 @@ export class IssuesStore {
}
}
}
function assertPersisted(
repo: GitHubRepository
): asserts repo is GitHubRepository & { dbID: number } {
if (repo.dbID === null) {
throw new Error(
`Need a GitHubRepository that's been inserted into the database`
)
}
}

View file

@ -29,9 +29,7 @@ export class PullRequestCoordinator {
* All `Repository`s in RepositoryStore associated with `GitHubRepository`
* This is updated whenever `RepositoryStore` emits an update
*/
private repositories: ReadonlyArray<
RepositoryWithGitHubRepository
> = new Array<RepositoryWithGitHubRepository>()
private repositories: Promise<ReadonlyArray<RepositoryWithGitHubRepository>>
/**
* Contains the last set of PRs retrieved by `PullRequestCoordinator`
@ -53,10 +51,21 @@ export class PullRequestCoordinator {
) {
// register an update handler for the repositories store
this.repositoriesStore.onDidUpdate(allRepositories => {
this.repositories = allRepositories.filter(
isRepositoryWithGitHubRepository
this.repositories = Promise.resolve(
allRepositories.filter(isRepositoryWithGitHubRepository)
)
})
// The `onDidUpdate` event only triggers when the list of repositories
// changes or a repository's information is changed. This may now happen for
// a very long time so we need to eagerly load the list of repositories.
this.repositories = this.repositoriesStore
.getAll()
.then(x => x.filter(isRepositoryWithGitHubRepository))
.catch(e => {
log.error(`PullRequestCoordinator: Error loading repositories`)
return []
})
}
/**
@ -80,16 +89,13 @@ export class PullRequestCoordinator {
) => void
): Disposable {
return this.pullRequestStore.onPullRequestsChanged(
(ghRepo, pullRequests) => {
// update cache
if (ghRepo.dbID !== null) {
this.prCache.set(ghRepo.dbID, pullRequests)
}
async (ghRepo, pullRequests) => {
this.prCache.set(ghRepo.dbID, pullRequests)
// find all related repos
const matches = findRepositoriesForGitHubRepository(
ghRepo,
this.repositories
await this.repositories
)
// emit updates for matches
@ -147,7 +153,7 @@ export class PullRequestCoordinator {
// get all matches for the repository to be refreshed
const matches = findRepositoriesForGitHubRepository(
gitHubRepository,
this.repositories
await this.repositories
)
// mark all matching repos as now loading
for (const match of matches) {
@ -235,20 +241,13 @@ export class PullRequestCoordinator {
private async getPullRequestsFor(
gitHubRepository: GitHubRepository
): Promise<ReadonlyArray<PullRequest>> {
const { dbID } = gitHubRepository
// this check should never be true, but we have to check
// for typescript and provide a sensible fallback
if (dbID === null) {
return []
}
if (!this.prCache.has(dbID)) {
if (!this.prCache.has(gitHubRepository.dbID)) {
this.prCache.set(
dbID,
gitHubRepository.dbID,
await this.pullRequestStore.getAll(gitHubRepository)
)
}
return this.prCache.get(dbID) || []
return this.prCache.get(gitHubRepository.dbID) || []
}
}

View file

@ -49,34 +49,23 @@ export class PullRequestStore {
/** Loads all pull requests against the given repository. */
public refreshPullRequests(repo: GitHubRepository, account: Account) {
const dbId = repo.dbID
if (dbId === null) {
// This can happen when the `repositoryWithRefreshedGitHubRepository`
// method in AppStore fails to retrieve API information about the current
// repository either due to the user being signed out or the API failing
// to provide a response. There's nothing for us to do when that happens
// so instead of crashing we'll bail here.
return Promise.resolve()
}
const currentOp = this.currentRefreshOperations.get(dbId)
const currentOp = this.currentRefreshOperations.get(repo.dbID)
if (currentOp !== undefined) {
return currentOp
}
this.lastRefreshForRepository.set(dbId, Date.now())
this.lastRefreshForRepository.set(repo.dbID, Date.now())
const promise = this.fetchAndStorePullRequests(repo, account)
.catch(err => {
log.error(`Error refreshing pull requests for '${repo.fullName}'`, err)
})
.then(() => {
this.currentRefreshOperations.delete(dbId)
this.currentRefreshOperations.delete(repo.dbID)
})
this.currentRefreshOperations.set(dbId, promise)
this.currentRefreshOperations.set(repo.dbID, promise)
return promise
}
@ -169,15 +158,6 @@ export class PullRequestStore {
/** Gets all stored pull requests for the given repository. */
public async getAll(repository: GitHubRepository) {
if (repository.dbID === null) {
// This can happen when the `repositoryWithRefreshedGitHubRepository`
// method in AppStore fails to retrieve API information about the current
// repository either due to the user being signed out or the API failing
// to provide a response. There's nothing for us to do when that happens
// so instead of crashing we'll bail here.
return []
}
const records = await this.db.getAllPullRequestsInRepository(repository)
const result = new Array<PullRequest>()
@ -272,7 +252,7 @@ export class PullRequestStore {
// to use the upsert just to ensure that the repo exists in the database
// and reuse the same object without going to the database for all that
// follow.
const upsertRepo = mem(store.upsertGitHubRepository.bind(store), {
const upsertRepo = mem(store.upsertGitHubRepositoryLight.bind(store), {
// The first argument which we're ignoring here is the endpoint
// which is constant throughout the lifetime of this function.
// The second argument is an `IAPIRepository` which is basically
@ -299,10 +279,6 @@ export class PullRequestStore {
const baseGitHubRepo = await upsertRepo(endpoint, pr.base.repo)
if (baseGitHubRepo.dbID === null) {
return fatalError('PR cannot have a null parent database id')
}
if (pr.state === 'closed') {
prsToDelete.push(getPullRequestKey(baseGitHubRepo, pr.number))
continue
@ -325,10 +301,6 @@ export class PullRequestStore {
const headRepo = await upsertRepo(endpoint, pr.head.repo)
if (headRepo.dbID === null) {
return fatalError('PR cannot have non-existent repo')
}
prsToUpsert.push({
number: pr.number,
title: pr.title,

View file

@ -1,27 +1,32 @@
import {
RepositoriesDatabase,
IDatabaseGitHubRepository,
IDatabaseOwner,
IDatabaseProtectedBranch,
IDatabaseRepository,
} from '../databases/repositories-database'
import { Owner } from '../../models/owner'
import {
GitHubRepository,
GitHubRepositoryPermission,
} from '../../models/github-repository'
import { Repository } from '../../models/repository'
import { fatalError } from '../fatal-error'
import { IAPIRepository, IAPIBranch, IAPIRepositoryPermissions } from '../api'
import {
Repository,
RepositoryWithGitHubRepository,
assertIsRepositoryWithGitHubRepository,
isRepositoryWithGitHubRepository,
} from '../../models/repository'
import { fatalError, assertNonNullable } from '../fatal-error'
import { IAPIRepository, IAPIBranch, IAPIFullRepository } from '../api'
import { TypedBaseStore } from './base-store'
import { WorkflowPreferences } from '../../models/workflow-preferences'
import { clearTagsToPush } from './helpers/tags-to-push-storage'
import { IMatchedGitHubRepository } from '../repository-matching'
import { shallowEquals } from '../equality'
/** The store for local repositories. */
export class RepositoriesStore extends TypedBaseStore<
ReadonlyArray<Repository>
> {
private db: RepositoriesDatabase
// Key-repo ID, Value-date
private lastStashCheckCache = new Map<number, number>()
@ -37,77 +42,109 @@ export class RepositoriesStore extends TypedBaseStore<
*/
private protectionEnabledForBranchCache = new Map<string, boolean>()
public constructor(db: RepositoriesDatabase) {
super()
private emitQueued = false
this.db = db
public constructor(private readonly db: RepositoriesDatabase) {
super()
}
/** Find the matching GitHub repository or add it if it doesn't exist. */
public async upsertGitHubRepository(
/**
* Insert or update the GitHub repository database record based on the
* provided API information while preserving any knowledge of the repository's
* parent.
*
* See the documentation inside putGitHubRepository for more information but
* the TL;DR is that if you've got an IAPIRepository you should use this
* method and if you've got an IAPIFullRepository you should use
* `upsertGitHubRepository`
*/
public async upsertGitHubRepositoryLight(
endpoint: string,
apiRepository: IAPIRepository
): Promise<GitHubRepository> {
) {
return this.db.transaction(
'rw',
this.db.repositories,
this.db.gitHubRepositories,
this.db.owners,
async () => {
const gitHubRepository = await this.db.gitHubRepositories
.where('cloneURL')
.equals(apiRepository.clone_url)
.limit(1)
.first()
if (gitHubRepository == null) {
return this.putGitHubRepository(endpoint, apiRepository)
} else {
return this.buildGitHubRepository(gitHubRepository)
}
}
() => this._upsertGitHubRepository(endpoint, apiRepository, true)
)
}
private async buildGitHubRepository(
dbRepo: IDatabaseGitHubRepository
/**
* Insert or update the GitHub repository database record based on the
* provided API information
*/
public async upsertGitHubRepository(
endpoint: string,
apiRepository: IAPIFullRepository
): Promise<GitHubRepository> {
const owner = await this.db.owners.get(dbRepo.ownerID)
return this.db.transaction(
'rw',
this.db.gitHubRepositories,
this.db.owners,
() => this._upsertGitHubRepository(endpoint, apiRepository, false)
)
}
if (owner == null) {
throw new Error(`Couldn't find repository owner ${dbRepo.ownerID}`)
private async toGitHubRepository(
repo: IDatabaseGitHubRepository,
owner?: Owner,
parent?: GitHubRepository | null
): Promise<GitHubRepository> {
assertNonNullable(repo.id, 'Need db id to create GitHubRepository')
// Note the difference between parent being null and undefined. Null means
// that the caller explicitly wants us to initialize a GitHubRepository
// without a parent, undefined means we should try to dig it up.
if (parent === undefined && repo.parentID !== null) {
const dbParent = await this.db.gitHubRepositories.get(repo.parentID)
assertNonNullable(dbParent, `Missing parent '${repo.id}'`)
parent = await this.toGitHubRepository(dbParent)
}
let parent: GitHubRepository | null = null
if (dbRepo.parentID) {
parent = await this.findGitHubRepositoryByID(dbRepo.parentID)
if (owner === undefined) {
const dbOwner = await this.db.owners.get(repo.ownerID)
assertNonNullable(dbOwner, `Missing owner '${repo.ownerID}'`)
owner = new Owner(dbOwner.login, dbOwner.endpoint, dbOwner.id!)
}
return new GitHubRepository(
dbRepo.name,
new Owner(owner.login, owner.endpoint, owner.id!),
dbRepo.id!,
dbRepo.private,
dbRepo.htmlURL,
dbRepo.defaultBranch,
dbRepo.cloneURL,
dbRepo.issuesEnabled,
dbRepo.isArchived,
dbRepo.permissions,
repo.name,
owner,
repo.id,
repo.private,
repo.htmlURL,
repo.defaultBranch,
repo.cloneURL,
repo.issuesEnabled,
repo.isArchived,
repo.permissions,
parent
)
}
private async toRepository(repo: IDatabaseRepository) {
assertNonNullable(repo.id, "can't convert to Repository without id")
return new Repository(
repo.path,
repo.id,
repo.gitHubRepositoryID !== null
? await this.findGitHubRepositoryByID(repo.gitHubRepositoryID)
: await Promise.resolve(null), // Dexie gets confused if we return null
repo.missing,
repo.workflowPreferences,
repo.isTutorialRepository
)
}
/** Find a GitHub repository by its DB ID. */
public async findGitHubRepositoryByID(
id: number
): Promise<GitHubRepository | null> {
const gitHubRepository = await this.db.gitHubRepositories.get(id)
if (!gitHubRepository) {
return null
}
return this.buildGitHubRepository(gitHubRepository)
return gitHubRepository !== undefined
? this.toGitHubRepository(gitHubRepository)
: Promise.resolve(null) // Dexie gets confused if we return null
}
/** Get all the local repositories. */
@ -118,29 +155,14 @@ export class RepositoriesStore extends TypedBaseStore<
this.db.gitHubRepositories,
this.db.owners,
async () => {
const inflatedRepos = new Array<Repository>()
const repos = await this.db.repositories.toArray()
for (const repo of repos) {
let inflatedRepo: Repository | null = null
let gitHubRepository: GitHubRepository | null = null
if (repo.gitHubRepositoryID) {
gitHubRepository = await this.findGitHubRepositoryByID(
repo.gitHubRepositoryID
)
}
const repos = new Array<Repository>()
inflatedRepo = new Repository(
repo.path,
repo.id!,
gitHubRepository,
repo.missing,
repo.workflowPreferences,
repo.isTutorialRepository
)
inflatedRepos.push(inflatedRepo)
for (const dbRepo of await this.db.repositories.toArray()) {
assertNonNullable(dbRepo.id, 'no id after loading from db')
repos.push(await this.toRepository(dbRepo))
}
return inflatedRepos
return repos
}
)
}
@ -148,18 +170,17 @@ export class RepositoriesStore extends TypedBaseStore<
/**
* Add a tutorial repository.
*
* This method differs from the `addRepository` method in that it
* requires that the repository has been created on the remote and
* set up to track it. Given that tutorial repositories are created
* from the no-repositories blank slate it shouldn't be possible for
* another repository with the same path to exist but in case that
* changes in the future this method will set the tutorial flag on
* the existing repository at the given path.
* This method differs from the `addRepository` method in that it requires
* that the repository has been created on the remote and set up to track it.
* Given that tutorial repositories are created from the no-repositories blank
* slate it shouldn't be possible for another repository with the same path to
* exist but in case that changes in the future this method will set the
* tutorial flag on the existing repository at the given path.
*/
public async addTutorialRepository(
path: string,
endpoint: string,
apiRepository: IAPIRepository
apiRepo: IAPIFullRepository
) {
await this.db.transaction(
'rw',
@ -167,25 +188,17 @@ export class RepositoriesStore extends TypedBaseStore<
this.db.gitHubRepositories,
this.db.owners,
async () => {
const gitHubRepository = await this.upsertGitHubRepository(
endpoint,
apiRepository
)
const ghRepo = await this.upsertGitHubRepository(endpoint, apiRepo)
const existingRepo = await this.db.repositories.get({ path })
const existingRepoId =
existingRepo && existingRepo.id !== null ? existingRepo.id : undefined
return await this.db.repositories.put(
{
path,
gitHubRepositoryID: gitHubRepository.dbID,
missing: false,
lastStashCheckDate: null,
isTutorialRepository: true,
},
existingRepoId
)
return await this.db.repositories.put({
...(existingRepo?.id !== undefined && { id: existingRepo.id }),
path,
gitHubRepositoryID: ghRepo.dbID,
missing: false,
lastStashCheckDate: null,
isTutorialRepository: true,
})
}
)
@ -204,29 +217,20 @@ export class RepositoriesStore extends TypedBaseStore<
this.db.gitHubRepositories,
this.db.owners,
async () => {
const repos = await this.db.repositories.toArray()
const record = repos.find(r => r.path === path)
let recordId: number
let gitHubRepo: GitHubRepository | null = null
const existing = await this.db.repositories.get({ path })
if (record != null) {
recordId = record.id!
if (record.gitHubRepositoryID != null) {
gitHubRepo = await this.findGitHubRepositoryByID(
record.gitHubRepositoryID
)
}
} else {
recordId = await this.db.repositories.add({
path,
gitHubRepositoryID: null,
missing: false,
lastStashCheckDate: null,
})
if (existing !== undefined) {
return await this.toRepository(existing)
}
return new Repository(path, recordId, gitHubRepo, false)
const dbRepo: IDatabaseRepository = {
path,
gitHubRepositoryID: null,
missing: false,
lastStashCheckDate: null,
}
const id = await this.db.repositories.add(dbRepo)
return this.toRepository({ id, ...dbRepo })
}
)
@ -248,14 +252,7 @@ export class RepositoriesStore extends TypedBaseStore<
repository: Repository,
missing: boolean
): Promise<Repository> {
const repoID = repository.id
if (!repoID) {
return fatalError(
'`updateRepositoryMissing` can only update `missing` for a repository which has been added to the database.'
)
}
await this.db.repositories.update(repoID, { missing })
await this.db.repositories.update(repository.id, { missing })
this.emitUpdatedRepositories()
@ -279,15 +276,7 @@ export class RepositoriesStore extends TypedBaseStore<
repository: Repository,
workflowPreferences: WorkflowPreferences
): Promise<void> {
const repoID = repository.id
if (!repoID) {
return fatalError(
'`updateRepositoryWorkflowPreferences` can only update `workflowPreferences` for a repository which has been added to the database.'
)
}
await this.db.repositories.update(repoID, { workflowPreferences })
await this.db.repositories.update(repository.id, { workflowPreferences })
this.emitUpdatedRepositories()
}
@ -297,17 +286,7 @@ export class RepositoriesStore extends TypedBaseStore<
repository: Repository,
path: string
): Promise<Repository> {
const repoID = repository.id
if (!repoID) {
return fatalError(
'`updateRepositoryPath` can only update the path for a repository which has been added to the database.'
)
}
await this.db.repositories.update(repoID, {
missing: false,
path,
})
await this.db.repositories.update(repository.id, { missing: false, path })
this.emitUpdatedRepositories()
@ -332,18 +311,11 @@ export class RepositoriesStore extends TypedBaseStore<
repository: Repository,
date: number = Date.now()
): Promise<void> {
const repoID = repository.id
if (repoID === 0) {
return fatalError(
'`updateLastStashCheckDate` can only update the last stash check date for a repository which has been added to the database.'
)
}
await this.db.repositories.update(repoID, {
await this.db.repositories.update(repository.id, {
lastStashCheckDate: date,
})
this.lastStashCheckCache.set(repoID, date)
this.lastStashCheckCache.set(repository.id, date)
// this update doesn't affect the list (or its items) we emit from this store, so no need to `emitUpdatedRepositories`
}
@ -356,29 +328,22 @@ export class RepositoriesStore extends TypedBaseStore<
public async getLastStashCheckDate(
repository: Repository
): Promise<number | null> {
const repoID = repository.id
if (!repoID) {
return fatalError(
'`getLastStashCheckDate` - can only retrieve the last stash check date for a repositories that have been stored in the database.'
)
}
let lastCheckDate = this.lastStashCheckCache.get(repoID) || null
let lastCheckDate = this.lastStashCheckCache.get(repository.id) || null
if (lastCheckDate !== null) {
return lastCheckDate
}
const record = await this.db.repositories.get(repoID)
const record = await this.db.repositories.get(repository.id)
if (record === undefined) {
return fatalError(
`'getLastStashCheckDate' - unable to find repository with ID: ${repoID}`
`'getLastStashCheckDate' - unable to find repository with ID: ${repository.id}`
)
}
lastCheckDate = record.lastStashCheckDate
lastCheckDate = record.lastStashCheckDate ?? null
if (lastCheckDate !== null) {
this.lastStashCheckCache.set(repoID, lastCheckDate)
this.lastStashCheckCache.set(repository.id, lastCheckDate)
}
return lastCheckDate
@ -391,126 +356,164 @@ export class RepositoriesStore extends TypedBaseStore<
.where('[endpoint+login]')
.equals([endpoint, login])
.first()
if (existingOwner) {
return new Owner(login, endpoint, existingOwner.id!)
}
const dbOwner: IDatabaseOwner = {
login,
endpoint,
}
const id = await this.db.owners.add(dbOwner)
const id = await this.db.owners.add({ login, endpoint })
return new Owner(login, endpoint, id)
}
private async putGitHubRepository(
endpoint: string,
gitHubRepository: IAPIRepository
): Promise<GitHubRepository> {
let parent: GitHubRepository | null = null
if (gitHubRepository.parent) {
parent = await this.putGitHubRepository(endpoint, gitHubRepository.parent)
public async upsertGitHubRepositoryFromMatch(
match: IMatchedGitHubRepository
) {
return await this.db.transaction(
'rw',
this.db.gitHubRepositories,
this.db.owners,
async () => {
const { account } = match
const owner = await this.putOwner(account.endpoint, match.owner)
const existingRepo = await this.db.gitHubRepositories
.where('[ownerID+name]')
.equals([owner.id, match.name])
.first()
if (existingRepo) {
return this.toGitHubRepository(existingRepo, owner)
}
const skeletonRepo: IDatabaseGitHubRepository = {
cloneURL: null,
defaultBranch: null,
htmlURL: null,
lastPruneDate: null,
name: match.name,
ownerID: owner.id,
parentID: null,
private: null,
}
const id = await this.db.gitHubRepositories.put(skeletonRepo)
return this.toGitHubRepository({ ...skeletonRepo, id }, owner, null)
}
)
}
public async setGitHubRepository(repo: Repository, ghRepo: GitHubRepository) {
// If nothing has changed we can skip writing to the database and (more
// importantly) avoid telling store consumers that the repo store has
// changed and just return the repo that was given to us.
if (isRepositoryWithGitHubRepository(repo)) {
if (repo.gitHubRepository.hash === ghRepo.hash) {
return repo
}
}
await this.db.transaction('rw', this.db.repositories, () =>
this.db.repositories.update(repo.id, { gitHubRepositoryID: ghRepo.dbID })
)
this.emitUpdatedRepositories()
const updatedRepo = new Repository(
repo.path,
repo.id,
ghRepo,
repo.missing,
repo.workflowPreferences,
repo.isTutorialRepository
)
assertIsRepositoryWithGitHubRepository(updatedRepo)
return updatedRepo
}
private async _upsertGitHubRepository(
endpoint: string,
gitHubRepository: IAPIRepository | IAPIFullRepository,
ignoreParent = false
): Promise<GitHubRepository> {
const parent =
'parent' in gitHubRepository && gitHubRepository.parent !== undefined
? await this._upsertGitHubRepository(
endpoint,
gitHubRepository.parent,
true
)
: await Promise.resolve(null) // Dexie gets confused if we return null
const login = gitHubRepository.owner.login.toLowerCase()
const owner = await this.putOwner(endpoint, login)
const existingRepo = await this.db.gitHubRepositories
.where('[ownerID+name]')
.equals([owner.id!, gitHubRepository.name])
.equals([owner.id, gitHubRepository.name])
.first()
// If we can't resolve permissions for the current repository
// chances are that it's because it's the parent repository of
// another repository and we ended up here because the "actual"
// repository is trying to upsert its parent. Since parent
// repository hashes don't include a permissions hash and since
// it's possible that the user has both the fork and the parent
// repositories in the app we don't want to overwrite the permissions
// hash in the parent repository if we can help it or else we'll
// end up in a perpetual race condition where updating the fork
// will clear the permissions on the parent and updating the parent
// will reinstate them.
// If we can't resolve permissions for the current repository chances are
// that it's because it's the parent repository of another repository and we
// ended up here because the "actual" repository is trying to upsert its
// parent. Since parent repository hashes don't include a permissions hash
// and since it's possible that the user has both the fork and the parent
// repositories in the app we don't want to overwrite the permissions hash
// in the parent repository if we can help it or else we'll end up in a
// perpetual race condition where updating the fork will clear the
// permissions on the parent and updating the parent will reinstate them.
const permissions =
getPermissionsString(gitHubRepository.permissions) ||
(existingRepo ? existingRepo.permissions : undefined)
getPermissionsString(gitHubRepository) ??
existingRepo?.permissions ??
undefined
let updatedGitHubRepo: IDatabaseGitHubRepository = {
ownerID: owner.id!,
// If we're told to ignore the parent then we'll attempt to use the existing
// parent and if that fails set it to null. This happens when we want to
// ensure we have a GitHubRepository record but we acquired the API data for
// said repository from an API endpoint that doesn't include the parent
// property like when loading pull requests. Similarly even when retrieving
// a full API repository its parent won't be a full repo so we'll never know
// if the parent of a repository has a parent (confusing, right?)
//
// We do all this to ensure that we only set the parent to null when we know
// that it needs to be cleared. Otherwise we could have a scenario where
// we've got a repository network where C is a fork of B and B is a fork of
// A which is the root. If we attempt to upsert C without these checks in
// place we'd wipe our knowledge of B being a fork of A.
//
// Since going from having a parent to not having a parent is incredibly
// rare (deleting a forked repository and creating it from scratch again
// with the same name or the parent getting deleted, etc) we assume that the
// value we've got is valid until we're certain its not.
const parentID = ignoreParent
? existingRepo?.parentID ?? null
: parent?.dbID ?? null
const updatedGitHubRepo: IDatabaseGitHubRepository = {
...(existingRepo?.id !== undefined && { id: existingRepo.id }),
ownerID: owner.id,
name: gitHubRepository.name,
private: gitHubRepository.private,
htmlURL: gitHubRepository.html_url,
defaultBranch: gitHubRepository.default_branch,
cloneURL: gitHubRepository.clone_url,
parentID: parent ? parent.dbID : null,
lastPruneDate: null,
parentID,
lastPruneDate: existingRepo?.lastPruneDate ?? null,
issuesEnabled: gitHubRepository.has_issues,
isArchived: gitHubRepository.archived,
permissions,
}
if (existingRepo) {
updatedGitHubRepo = { ...updatedGitHubRepo, id: existingRepo.id }
if (existingRepo !== undefined) {
// If nothing has changed since the last time we persisted the API info
// we can skip writing to the database and (more importantly) avoid
// telling store consumers that the repo store has changed.
if (shallowEquals(existingRepo, updatedGitHubRepo)) {
return this.toGitHubRepository(existingRepo, owner, parent)
}
}
const id = await this.db.gitHubRepositories.put(updatedGitHubRepo)
return new GitHubRepository(
updatedGitHubRepo.name,
owner,
id,
updatedGitHubRepo.private,
updatedGitHubRepo.htmlURL,
updatedGitHubRepo.defaultBranch,
updatedGitHubRepo.cloneURL,
updatedGitHubRepo.issuesEnabled,
updatedGitHubRepo.isArchived,
updatedGitHubRepo.permissions,
parent
)
}
/** Add or update the repository's GitHub repository. */
public async updateGitHubRepository(
repository: Repository,
endpoint: string,
gitHubRepository: IAPIRepository
): Promise<Repository> {
const repoID = repository.id
if (!repoID) {
return fatalError(
'`updateGitHubRepository` can only update a GitHub repository for a repository which has been added to the database.'
)
}
const updatedGitHubRepo = await this.db.transaction(
'rw',
this.db.repositories,
this.db.gitHubRepositories,
this.db.owners,
async () => {
const localRepo = (await this.db.repositories.get(repoID))!
const updatedGitHubRepo = await this.putGitHubRepository(
endpoint,
gitHubRepository
)
await this.db.repositories.update(localRepo.id!, {
gitHubRepositoryID: updatedGitHubRepo.dbID,
})
return updatedGitHubRepo
}
)
this.emitUpdatedRepositories()
return new Repository(
repository.path,
repository.id,
updatedGitHubRepo,
repository.missing,
repository.workflowPreferences,
repository.isTutorialRepository
)
return this.toGitHubRepository({ ...updatedGitHubRepo, id }, owner, parent)
}
/** Add or update the branch protections associated with a GitHub repository. */
@ -519,11 +522,6 @@ export class RepositoriesStore extends TypedBaseStore<
protectedBranches: ReadonlyArray<IAPIBranch>
): Promise<void> {
const dbID = gitHubRepository.dbID
if (!dbID) {
return fatalError(
'`updateBranchProtections` can only update a GitHub repository for a repository which has been added to the database.'
)
}
await this.db.transaction('rw', this.db.protectedBranches, async () => {
// This update flow is organized into two stages:
@ -544,10 +542,7 @@ export class RepositoriesStore extends TypedBaseStore<
}
const branchRecords = protectedBranches.map<IDatabaseProtectedBranch>(
b => ({
repoId: dbID,
name: b.name,
})
b => ({ repoId: dbID, name: b.name })
)
// update cached values to avoid database lookup
@ -576,31 +571,10 @@ export class RepositoriesStore extends TypedBaseStore<
* @param date The date and time in which the last prune took place
*/
public async updateLastPruneDate(
repository: Repository,
repository: RepositoryWithGitHubRepository,
date: number
): Promise<void> {
const repoID = repository.id
if (repoID === 0) {
return fatalError(
'`updateLastPruneDate` can only update the last prune date for a repository which has been added to the database.'
)
}
const githubRepo = repository.gitHubRepository
if (githubRepo === null) {
return fatalError(
`'updateLastPruneDate' can only update GitHub repositories`
)
}
const gitHubRepositoryID = githubRepo.dbID
if (gitHubRepositoryID === null) {
return fatalError(
`'updateLastPruneDate' can only update GitHub repositories with a valid ID: received ID of ${gitHubRepositoryID}`
)
}
await this.db.gitHubRepositories.update(gitHubRepositoryID, {
await this.db.gitHubRepositories.update(repository.gitHubRepository.dbID, {
lastPruneDate: date,
})
@ -608,38 +582,16 @@ export class RepositoriesStore extends TypedBaseStore<
}
public async getLastPruneDate(
repository: Repository
repository: RepositoryWithGitHubRepository
): Promise<number | null> {
const repoID = repository.id
if (!repoID) {
return fatalError(
'`getLastPruneDate` - can only retrieve the last prune date for a repositories that have been stored in the database.'
)
}
const githubRepo = repository.gitHubRepository
if (githubRepo === null) {
return fatalError(
`'getLastPruneDate' - can only retrieve the last prune date for GitHub repositories.`
)
}
const gitHubRepositoryID = githubRepo.dbID
if (gitHubRepositoryID === null) {
return fatalError(
`'getLastPruneDate' - can only retrieve the last prune date for GitHub repositories that have been stored in the database.`
)
}
const record = await this.db.gitHubRepositories.get(gitHubRepositoryID)
const id = repository.gitHubRepository.dbID
const record = await this.db.gitHubRepositories.get(id)
if (record === undefined) {
return fatalError(
`'getLastPruneDate' - unable to find GitHub repository with ID: ${gitHubRepositoryID}`
)
return fatalError(`getLastPruneDate: No such GitHub repository: ${id}`)
}
return record!.lastPruneDate
return record.lastPruneDate
}
/**
@ -672,19 +624,12 @@ export class RepositoriesStore extends TypedBaseStore<
public async hasBranchProtectionsConfigured(
gitHubRepository: GitHubRepository
): Promise<boolean> {
if (gitHubRepository.dbID === null) {
return fatalError(
'unable to get protected branches, GitHub repository has a null dbID'
)
}
const { dbID } = gitHubRepository
const branchProtectionsFound = this.branchProtectionSettingsFoundCache.get(
dbID
gitHubRepository.dbID
)
if (branchProtectionsFound === undefined) {
return this.loadAndCacheBranchProtection(dbID)
return this.loadAndCacheBranchProtection(gitHubRepository.dbID)
}
return branchProtectionsFound
@ -694,8 +639,16 @@ export class RepositoriesStore extends TypedBaseStore<
* Helper method to emit updates consistently
* (This is the only way we emit updates from this store.)
*/
private async emitUpdatedRepositories() {
this.emitUpdate(await this.getAll())
private emitUpdatedRepositories() {
if (!this.emitQueued) {
setImmediate(() => {
this.getAll()
.then(repos => this.emitUpdate(repos))
.catch(e => log.error(`Failed emitting update`, e))
.finally(() => (this.emitQueued = false))
})
this.emitQueued = true
}
}
}
@ -710,9 +663,11 @@ function getKeyPrefix(dbID: number) {
}
function getPermissionsString(
permissions: IAPIRepositoryPermissions | undefined
repo: IAPIRepository | IAPIFullRepository
): GitHubRepositoryPermission {
if (!permissions) {
const permissions = 'permissions' in repo ? repo.permissions : undefined
if (permissions === undefined) {
return null
} else if (permissions.admin) {
return 'admin'

View file

@ -19,7 +19,6 @@ import {
IRebaseState,
ChangesSelectionKind,
} from '../app-state'
import { ComparisonCache } from '../comparison-cache'
import { merge } from '../merge'
import { DefaultCommitMessage } from '../../models/commit-message'
@ -140,11 +139,6 @@ function getInitialRepositoryState(): IRepositoryState {
rebasedBranches: new Map<string, string>(),
},
compareState: {
divergingBranchBannerState: {
isPromptVisible: false,
isPromptDismissed: false,
isNudgeVisible: false,
},
formState: {
kind: HistoryTabMode.History,
},
@ -153,11 +147,9 @@ function getInitialRepositoryState(): IRepositoryState {
showBranchList: false,
filterText: '',
commitSHAs: [],
aheadBehindCache: new ComparisonCache(),
allBranches: new Array<Branch>(),
branches: new Array<Branch>(),
recentBranches: new Array<Branch>(),
defaultBranch: null,
inferredComparisonBranch: { branch: null, aheadBehind: null },
},
rebaseState: {
step: null,

View file

@ -29,7 +29,7 @@ function getUnverifiedUserErrorMessage(login: string): string {
return `Unable to authenticate. The account ${login} is lacking a verified email address. Please sign in to GitHub.com, confirm your email address in the Emails section under Personal settings, and try again.`
}
const EnterpriseTooOldMessage = `The GitHub Enterprise Server version does not support GitHub Desktop. Talk to your server's administrator about upgrading to the latest version of GitHub Enterprise Server.`
const EnterpriseTooOldMessage = `The GitHub Enterprise version does not support GitHub Desktop. Talk to your server's administrator about upgrading to the latest version of GitHub Enterprise.`
/**
* An enumeration of the possible steps that the sign in
@ -80,7 +80,7 @@ export interface ISignInState {
/**
* State interface representing the endpoint entry step.
* This is the initial step in the Enterprise Server sign in
* This is the initial step in the Enterprise sign in
* flow and is not present when signing in to GitHub.com
*/
export interface IEndpointEntryState extends ISignInState {
@ -92,7 +92,7 @@ export interface IEndpointEntryState extends ISignInState {
* the user provides credentials and/or initiates a browser
* OAuth sign in process. This step occurs as the first step
* when signing in to GitHub.com and as the second step when
* signing in to a GitHub Enterprise Server instance.
* signing in to a GitHub Enterprise instance.
*/
export interface IAuthenticationState extends ISignInState {
readonly kind: SignInStep.Authentication
@ -101,7 +101,7 @@ export interface IAuthenticationState extends ISignInState {
* The URL to the host which we're currently authenticating
* against. This will be either https://api.github.com when
* signing in against GitHub.com or a user-specified
* URL when signing in against a GitHub Enterprise Server
* URL when signing in against a GitHub Enterprise
* instance.
*/
readonly endpoint: string
@ -109,7 +109,7 @@ export interface IAuthenticationState extends ISignInState {
/**
* A value indicating whether or not the endpoint supports
* basic authentication (i.e. username and password). All
* GitHub Enterprise Server instances support OAuth (or web
* GitHub Enterprise instances support OAuth (or web
* flow sign-in).
*/
readonly supportsBasicAuth: boolean
@ -124,7 +124,7 @@ export interface IAuthenticationState extends ISignInState {
* State interface representing the TwoFactorAuthentication
* step where the user provides an OTP token. This step
* occurs after the authentication step both for GitHub.com,
* and GitHub Enterprise Server when the user has enabled two
* and GitHub Enterprise when the user has enabled two
* factor authentication on the host.
*/
export interface ITwoFactorAuthenticationState extends ISignInState {
@ -134,7 +134,7 @@ export interface ITwoFactorAuthenticationState extends ISignInState {
* The URL to the host which we're currently authenticating
* against. This will be either https://api.github.com when
* signing in against GitHub.com or a user-specified
* URL when signing in against a GitHub Enterprise Server
* URL when signing in against a GitHub Enterprise
* instance.
*/
readonly endpoint: string
@ -193,7 +193,7 @@ const ServerMetaDataTimeout = 2000
/**
* A store encapsulating all logic related to signing in a user
* to GitHub.com, or a GitHub Enterprise Server instance.
* to GitHub.com, or a GitHub Enterprise instance.
*/
export class SignInStore extends TypedBaseStore<SignInState | null> {
private state: SignInState | null = null
@ -267,7 +267,7 @@ export class SignInStore extends TypedBaseStore<SignInState | null> {
}
throw new Error(
`Unable to authenticate with the GitHub Enterprise Server instance. Verify that the URL is correct, that your GitHub Enterprise Server instance is running version ${minimumSupportedEnterpriseVersion} or later, that you have an internet connection and try again.`
`Unable to authenticate with the GitHub Enterprise instance. Verify that the URL is correct, that your GitHub Enterprise instance is running version ${minimumSupportedEnterpriseVersion} or later, that you have an internet connection and try again.`
)
}
@ -488,7 +488,7 @@ export class SignInStore extends TypedBaseStore<SignInState | null> {
}
/**
* Initiate a sign in flow for a GitHub Enterprise Server instance.
* Initiate a sign in flow for a GitHub Enterprise instance.
* This will put the store in the EndpointEntry step ready to
* receive the url to the enterprise instance.
*/
@ -532,11 +532,11 @@ export class SignInStore extends TypedBaseStore<SignInState | null> {
let error = e
if (e.name === InvalidURLErrorName) {
error = new Error(
`The GitHub Enterprise Server instance address doesn't appear to be a valid URL. We're expecting something like https://github.example.com.`
`The GitHub Enterprise instance address doesn't appear to be a valid URL. We're expecting something like https://github.example.com.`
)
} else if (e.name === InvalidProtocolErrorName) {
error = new Error(
'Unsupported protocol. Only http or https is supported when authenticating with GitHub Enterprise Server instances.'
'Unsupported protocol. Only http or https is supported when authenticating with GitHub Enterprise instances.'
)
}

View file

@ -4,11 +4,11 @@ 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 Server. Web flow
* 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 Server it's
* 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.
*/

View file

@ -20,10 +20,14 @@ export class AppWindow {
private minWidth = 960
private minHeight = 660
// See https://github.com/desktop/desktop/pull/11162
private shouldMaximizeOnShow = false
public constructor() {
const savedWindowState = windowStateKeeper({
defaultWidth: this.minWidth,
defaultHeight: this.minHeight,
maximize: false,
})
const windowOptions: Electron.BrowserWindowConstructorOptions = {
@ -43,7 +47,7 @@ export class AppWindow {
disableBlinkFeatures: 'Auxclick',
nodeIntegration: true,
enableRemoteModule: true,
spellcheck: false,
spellcheck: true,
},
acceptFirstMouse: true,
}
@ -58,6 +62,7 @@ export class AppWindow {
this.window = new BrowserWindow(windowOptions)
savedWindowState.manage(this.window)
this.shouldMaximizeOnShow = savedWindowState.isMaximized
let quitting = false
app.on('before-quit', () => {
@ -205,6 +210,9 @@ export class AppWindow {
/** Show the window. */
public show() {
this.window.show()
if (this.shouldMaximizeOnShow) {
this.window.maximize()
}
}
/** Send the menu event to the renderer. */

View file

@ -26,24 +26,10 @@ import { now } from './now'
import { showUncaughtException } from './show-uncaught-exception'
import { ISerializableMenuItem } from '../lib/menu-item'
import { buildContextMenu } from './menu/build-context-menu'
import { sendNonFatalException } from '../lib/helpers/non-fatal-exception'
import { stat } from 'fs-extra'
import { isApplicationBundle } from '../lib/is-application-bundle'
app.setAppLogsPath()
/**
* While testing Electron 9 on Windows we were seeing fairly
* consistent hangs that seem similar to the following issues
*
* https://github.com/electron/electron/issues/24173
* https://github.com/electron/electron/issues/23910
* https://github.com/electron/electron/issues/24338
*
* TODO: Try removing when upgrading to Electron vNext
*/
app.allowRendererProcessReuse = false
enableSourceMaps()
let mainWindow: AppWindow | null = null
@ -633,17 +619,13 @@ app.on('web-contents-created', (event, contents) => {
contents.on('new-window', (event, url) => {
// Prevent links or window.open from opening new windows
event.preventDefault()
const errMsg = `Prevented new window to: ${url}`
log.warn(errMsg)
sendNonFatalException('newWindowPrevented', Error(errMsg))
log.warn(`Prevented new window to: ${url}`)
})
// prevent link navigation within our windows
// see https://www.electronjs.org/docs/tutorial/security#12-disable-or-limit-navigation
contents.on('will-navigate', (event, url) => {
event.preventDefault()
const errMsg = `Prevented navigation to: ${url}`
log.warn(errMsg)
sendNonFatalException('willNavigatePrevented', Error(errMsg))
log.warn(`Prevented navigation to: ${url}`)
})
})

View file

@ -584,7 +584,7 @@ function getStashedChangesLabel(isStashedChangesVisible: boolean): string {
type ClickHandler = (
menuItem: Electron.MenuItem,
browserWindow: Electron.BrowserWindow | undefined,
event: Electron.Event
event: Electron.KeyboardEvent
) => void
/**

View file

@ -1,4 +1,3 @@
import * as Url from 'url'
import { shell } from 'electron'
/**
@ -16,30 +15,18 @@ import { shell } from 'electron'
* @param path directory to open
*/
export function UNSAFE_openDirectory(path: string) {
if (__DARWIN__) {
const directoryURL = Url.format({
pathname: path,
protocol: 'file:',
slashes: true,
})
// Add a trailing slash to the directory path.
//
// On Windows, if there's a file and a directory with the
// same name (e.g `C:\MyFolder\foo` and `C:\MyFolder\foo.exe`),
// when executing shell.openItem(`C:\MyFolder\foo`) then the EXE file
// will get opened.
// We can avoid this by adding a final backslash at the end of the path.
const pathname = __WIN32__ && !path.endsWith('\\') ? `${path}\\` : path
shell
.openExternal(directoryURL)
.catch(err => log.error(`Failed to open directory (${path})`, err))
} else {
// Add a trailing slash to the directory path.
//
// On Windows, if there's a file and a directory with the
// same name (e.g `C:\MyFolder\foo` and `C:\MyFolder\foo.exe`),
// when executing shell.openItem(`C:\MyFolder\foo`) then the EXE file
// will get opened.
// We can avoid this by adding a final backslash at the end of the path.
const pathname = __WIN32__ && !path.endsWith('\\') ? `${path}\\` : path
shell.openPath(pathname).then(err => {
if (err !== '') {
log.error(`Failed to open directory (${path}): ${err}`)
}
})
}
shell.openPath(pathname).then(err => {
if (err !== '') {
log.error(`Failed to open directory (${path}): ${err}`)
}
})
}

View file

@ -1,7 +1,7 @@
import { getDotComAPIEndpoint, IAPIEmail } from '../lib/api'
/**
* A GitHub account, representing the user found on GitHub The Website or GitHub Enterprise Server.
* A GitHub account, representing the user found on GitHub The Website or GitHub Enterprise.
*
* This contains a token that will be used for operations that require authentication.
*/
@ -15,11 +15,11 @@ export class Account {
* Create an instance of an account
*
* @param login The login name for this account
* @param endpoint The server for this account - GitHub or a GitHub Enterprise Server instance
* @param endpoint The server for this account - GitHub or a GitHub Enterprise instance
* @param token The access token used to perform operations on behalf of this account
* @param emails The current list of email addresses associated with the account
* @param avatarURL The profile URL to render for this account
* @param id The GitHub.com or GitHub Enterprise Server database id for this account.
* @param id The GitHub.com or GitHub Enterprise database id for this account.
* @param name The friendly name associated with this account
*/
public constructor(

View file

@ -19,7 +19,7 @@ export interface IAuthor {
readonly email: string
/**
* The GitHub.com or GitHub Enterprise Server login for
* The GitHub.com or GitHub Enterprise login for
* this author or null if that information is not
* available.
*/

View file

@ -19,7 +19,7 @@ export interface IAvatarUser {
* The endpoint of the repository that this user is associated with.
* This will be https://api.github.com for GitHub.com-hosted
* repositories, something like `https://github.example.com/api/v3`
* for GitHub Enterprise Server and null for local repositories or
* for GitHub Enterprise and null for local repositories or
* repositories hosted on non-GitHub services.
*/
readonly endpoint: string | null

View file

@ -1,6 +1,7 @@
import { Commit } from './commit'
import { removeRemotePrefix } from '../lib/remove-remote-prefix'
import { CommitIdentity } from './commit-identity'
import { ForkedRemotePrefix } from './remote'
// NOTE: The values here matter as they are used to sort
// local and remote branches, Local should come before Remote
@ -20,6 +21,14 @@ export interface ICompareResult extends IAheadBehind {
readonly commits: ReadonlyArray<Commit>
}
/** Basic data about a branch, and the branch it's tracking. */
export interface ITrackingBranch {
readonly ref: string
readonly sha: string
readonly upstreamRef: string
readonly upstreamSha: string
}
/** Basic data about the latest commit on the branch. */
export interface IBranchTip {
readonly sha: string
@ -35,28 +44,6 @@ export enum StartPoint {
UpstreamDefaultBranch = 'UpstreamDefaultBranch',
}
/**
* Check if a branch is eligible for being fast-forwarded.
*
* Requirements:
* 1. It's local.
* 2. It's not the current branch.
* 3. It has an upstream.
*
* @param branch The branch to validate
* @param currentBranchName The current branch in the repository
*/
export function eligibleForFastForward(
branch: Branch,
currentBranchName: string | null
): boolean {
return (
branch.type === BranchType.Local &&
branch.name !== currentBranchName &&
branch.upstream != null
)
}
/** A branch as loaded from Git. */
export class Branch {
/**
@ -66,16 +53,18 @@ export class Branch {
* @param upstream The remote-prefixed upstream name. E.g., `origin/main`.
* @param tip Basic information (sha and author) of the latest commit on the branch.
* @param type The type of branch, e.g., local or remote.
* @param ref The canonical ref of the branch
*/
public constructor(
public readonly name: string,
public readonly upstream: string | null,
public readonly tip: IBranchTip,
public readonly type: BranchType
public readonly type: BranchType,
public readonly ref: string
) {}
/** The name of the upstream's remote. */
public get remote(): string | null {
public get upstreamRemoteName(): string | null {
const upstream = this.upstream
if (!upstream) {
return null
@ -89,6 +78,20 @@ export class Branch {
return pieces[1]
}
/** The name of remote for a remote branch. If local, will return null. */
public get remoteName(): string | null {
if (this.type === BranchType.Local) {
return null
}
const pieces = this.ref.match(/^refs\/remotes\/(.*?)\/.*/)
if (!pieces || pieces.length !== 2) {
// This shouldn't happen, the remote ref should always be prefixed
// with refs/remotes
throw new Error(`Remote branch ref has unexpected format: ${this.ref}`)
}
return pieces[1]
}
/**
* The name of the branch's upstream without the remote prefix.
*/
@ -112,4 +115,21 @@ export class Branch {
return withoutRemote || this.name
}
}
/**
* Gets a value indicating whether the branch is a remote branch belonging to
* one of Desktop's automatically created (and pruned) fork remotes. I.e. a
* remote branch from a branch which starts with `github-desktop-`.
*
* We hide branches from our known Desktop for remotes as these are considered
* plumbing and can add noise to everywhere in the user interface where we
* display branches as forks will likely contain duplicates of the same ref
* names
**/
public get isDesktopForkRemoteBranch() {
return (
this.type === BranchType.Remote &&
this.name.startsWith(ForkedRemotePrefix)
)
}
}

View file

@ -6,4 +6,6 @@ export type CloneOptions = {
readonly account: IGitAccount | null
/** The branch to checkout after the clone has completed. */
readonly branch?: string
/** The default branch name in case we're cloning an empty repository. */
readonly defaultBranch?: string
}

View file

@ -5,9 +5,9 @@
export class CommitIdentity {
/**
* Parses a Git ident string (GIT_AUTHOR_IDENT or GIT_COMMITTER_IDENT)
* into a commit identity. Returns null if string could not be parsed.
* into a commit identity. Throws an error if identify string is invalid.
*/
public static parseIdentity(identity: string): CommitIdentity | null {
public static parseIdentity(identity: string): CommitIdentity {
// See fmt_ident in ident.c:
// https://github.com/git/git/blob/3ef7618e6/ident.c#L346
//
@ -22,7 +22,7 @@ export class CommitIdentity {
//
const m = identity.match(/^(.*?) <(.*?)> (\d+) (\+|-)?(\d{2})(\d{2})/)
if (!m) {
return null
throw new Error(`Couldn't parse identity ${identity}`)
}
const name = m[1]
@ -31,6 +31,10 @@ export class CommitIdentity {
// Date() expects milliseconds since the epoch.
const date = new Date(parseInt(m[3], 10) * 1000)
if (isNaN(date.valueOf())) {
throw new Error(`Couldn't parse identity ${identity}, invalid date`)
}
// The RAW option never uses alphanumeric timezone identifiers and in my
// testing I've never found it to omit the leading + for a positive offset
// but the docs for strprintf seems to suggest it might on some systems so

View file

@ -0,0 +1,17 @@
/**
* Types which can safely be coerced to strings without losing information.
* As an example `1234.toString()` doesn't lose any information whereas
* `({ foo: bar }).toString()` does (`[Object object]`).
*/
type HashableType = number | string | boolean | undefined | null
/**
* Creates a string representation of the provided arguments.
*
* This is a helper function used to create a string representation of
* an object based on its properties for the purposes of simple equality
* comparisons.
*/
export function createEqualityHash(...items: HashableType[]) {
return items.join('+')
}

View file

@ -1,19 +1,25 @@
import { createEqualityHash } from './equality-hash'
import { Owner } from './owner'
export type GitHubRepositoryPermission = 'read' | 'write' | 'admin' | null
/** A GitHub repository. */
export class GitHubRepository {
/**
* A hash of the properties of the object.
*
* Objects with the same hash are guaranteed to be structurally equal.
*/
public readonly hash: string
public constructor(
public readonly name: string,
public readonly owner: Owner,
/**
* The ID of the repository in the app's local database. This is no relation
* to the API ID.
*
* May be `null` if it hasn't been inserted or retrieved from the database.
*/
public readonly dbID: number | null,
public readonly dbID: number,
public readonly isPrivate: boolean | null = null,
public readonly htmlURL: string | null = null,
public readonly defaultBranch: string | null = null,
@ -23,7 +29,21 @@ export class GitHubRepository {
/** The user's permissions for this github repository. `null` if unknown. */
public readonly permissions: GitHubRepositoryPermission = null,
public readonly parent: GitHubRepository | null = null
) {}
) {
this.hash = createEqualityHash(
this.name,
this.owner.login,
this.dbID,
this.isPrivate,
this.htmlURL,
this.defaultBranch,
this.cloneURL,
this.issuesEnabled,
this.isArchived,
this.permissions,
this.parent?.hash
)
}
public get endpoint(): string {
return this.owner.endpoint
@ -38,19 +58,6 @@ export class GitHubRepository {
public get fork(): boolean {
return !!this.parent
}
/**
* A hash of the properties of the object.
*
* Objects with the same hash are guaranteed to be structurally equal.
*/
public get hash(): string {
return `${this.dbID}+${this.defaultBranch}+${this.isPrivate}+${
this.cloneURL
}+${this.name}+${this.htmlURL}+${this.owner.hash}+${
this.parent && this.parent.hash
}`
}
}
/**

View file

@ -1,5 +1,4 @@
import { Shell } from '../lib/shells'
import { ExternalEditor } from '../lib/editors'
export type MenuLabelsEvent = {
/**
@ -16,7 +15,7 @@ export type MenuLabelsEvent = {
* Specify `null` to indicate that it is not known currently, which will
* default to a placeholder based on the current platform.
*/
readonly selectedExternalEditor: ExternalEditor | null
readonly selectedExternalEditor: string | null
/**
* Has the use enabled "Show confirmation dialog before force pushing"?

View file

@ -6,7 +6,7 @@ export class Owner {
public constructor(
public readonly login: string,
public readonly endpoint: string,
public readonly id: number | null
public readonly id: number
) {}
/**

View file

@ -19,6 +19,7 @@ import { ITextDiff, DiffSelection } from './diff'
export enum PopupType {
RenameBranch = 1,
DeleteBranch,
DeleteRemoteBranch,
ConfirmDiscardChanges,
Preferences,
MergeBranch,
@ -76,6 +77,11 @@ export type Popup =
branch: Branch
existsOnRemote: boolean
}
| {
type: PopupType.DeleteRemoteBranch
repository: Repository
branch: Branch
}
| {
type: PopupType.ConfirmDiscardChanges
repository: Repository
@ -106,8 +112,6 @@ export type Popup =
| {
type: PopupType.CreateBranch
repository: Repository
currentBranchProtected: boolean
initialName?: string
}
| { type: PopupType.SignIn }
@ -137,7 +141,7 @@ export type Popup =
| {
type: PopupType.ExternalEditorFailed
message: string
suggestAtom?: boolean
suggestDefaultEditor?: boolean
openPreferences?: boolean
}
| { type: PopupType.OpenShellFailed; message: string }

View file

@ -6,7 +6,8 @@ import {
WorkflowPreferences,
ForkContributionTarget,
} from './workflow-preferences'
import { assertNever } from '../lib/fatal-error'
import { assertNever, fatalError } from '../lib/fatal-error'
import { createEqualityHash } from './equality-hash'
function getBaseName(path: string): string {
const baseName = Path.basename(path)
@ -34,6 +35,13 @@ export class Repository {
*/
private readonly mainWorkTree: WorkingTree
/**
* A hash of the properties of the object.
*
* Objects with the same hash are guaranteed to be structurally equal.
*/
public hash: string
/**
* @param path The working directory of this repository
* @param missing Was the repository missing on disk last we checked?
@ -44,38 +52,29 @@ export class Repository {
public readonly gitHubRepository: GitHubRepository | null,
public readonly missing: boolean,
public readonly workflowPreferences: WorkflowPreferences = {},
private readonly _isTutorialRepository?: boolean
/**
* True if the repository is a tutorial repository created as part of the
* onboarding flow. Tutorial repositories trigger a tutorial user experience
* which introduces new users to some core concepts of Git and GitHub.
*/
public readonly isTutorialRepository: boolean = false
) {
this.mainWorkTree = { path }
this.name = (gitHubRepository && gitHubRepository.name) || getBaseName(path)
this.hash = createEqualityHash(
path,
this.id,
gitHubRepository?.hash,
this.missing,
this.workflowPreferences.forkContributionTarget,
this.isTutorialRepository
)
}
public get path(): string {
return this.mainWorkTree.path
}
/**
* A hash of the properties of the object.
*
* Objects with the same hash are guaranteed to be structurally equal.
*/
public get hash(): string {
return `${this.id}+${this.gitHubRepository && this.gitHubRepository.hash}+${
this.path
}+${this.missing}+${this.name}+${this.isTutorialRepository}+${
this.workflowPreferences.forkContributionTarget
}`
}
/**
* True if the repository is a tutorial repository created as part
* of the onboarding flow. Tutorial repositories trigger a tutorial
* user experience which introduces new users to some core concepts
* of Git and GitHub.
*/
public get isTutorialRepository() {
return this._isTutorialRepository === true
}
}
/** A worktree linked to a main working tree (aka `Repository`) */
@ -92,7 +91,7 @@ export type RepositoryWithGitHubRepository = Repository & {
/**
* Identical to `Repository`, except it **must** have a `gitHubRepository`
* which in turn must have a parent. In other words this is a GitHub (.com
* or Enterprise Server) fork.
* or Enterprise) fork.
*/
export type RepositoryWithForkedGitHubRepository = Repository & {
readonly gitHubRepository: ForkedGitHubRepository
@ -110,6 +109,17 @@ export function isRepositoryWithGitHubRepository(
return repository.gitHubRepository instanceof GitHubRepository
}
/**
* Asserts that the passed repository is a GitHub repository.
*/
export function assertIsRepositoryWithGitHubRepository(
repository: Repository
): asserts repository is RepositoryWithGitHubRepository {
if (!isRepositoryWithGitHubRepository(repository)) {
return fatalError(`Repository must be GitHub repository`)
}
}
/**
* Returns whether the passed repository is a GitHub fork.
*

View file

@ -74,7 +74,7 @@ function branchEquals(x: Branch, y: Branch) {
return (
x.type === y.type &&
x.tip.sha === y.tip.sha &&
x.remote === y.remote &&
x.upstreamRemoteName === y.upstreamRemoteName &&
x.upstream === y.upstream
)
}

View file

@ -1,74 +1,8 @@
import { IStashEntry } from './stash-entry'
import { assertNever } from '../lib/fatal-error'
export enum UncommittedChangesStrategyKind {
export enum UncommittedChangesStrategy {
AskForConfirmation = 'AskForConfirmation',
StashOnCurrentBranch = 'StashOnCurrentBranch',
MoveToNewBranch = 'MoveToNewBranch',
}
export const uncommittedChangesStrategyKindDefault: UncommittedChangesStrategyKind =
UncommittedChangesStrategyKind.AskForConfirmation
export type UncommittedChangesStrategy =
| { kind: UncommittedChangesStrategyKind.AskForConfirmation }
| { kind: UncommittedChangesStrategyKind.StashOnCurrentBranch }
| {
kind: UncommittedChangesStrategyKind.MoveToNewBranch
transientStashEntry: IStashEntry | null
}
export const askToStash: UncommittedChangesStrategy = {
kind: UncommittedChangesStrategyKind.AskForConfirmation,
}
export const stashOnCurrentBranch: UncommittedChangesStrategy = {
kind: UncommittedChangesStrategyKind.StashOnCurrentBranch,
}
export const moveToNewBranch: UncommittedChangesStrategy = {
kind: UncommittedChangesStrategyKind.MoveToNewBranch,
transientStashEntry: null,
}
/**
* Used to convert a `UncommittedChangesStrategyKind` into a
* `UncommittedChangesStrategy` object. For example, the
* user's preference is stored as a kind in state, which
* must be translated into a strategy before it can be
* used in stashing logic and methods.
*/
export function getUncommittedChangesStrategy(
kind: UncommittedChangesStrategyKind
): UncommittedChangesStrategy {
switch (kind) {
case UncommittedChangesStrategyKind.AskForConfirmation:
return askToStash
case UncommittedChangesStrategyKind.MoveToNewBranch:
return moveToNewBranch
case UncommittedChangesStrategyKind.StashOnCurrentBranch:
return stashOnCurrentBranch
default:
return assertNever(
kind,
`Unknown UncommittedChangesStrategyKind: ${kind}`
)
}
}
/**
* Parse a string into a valid `UncommittedChangesStrategyKind`,
* if possible. Returns `null` if not.
*/
export function parseStrategy(
strategy: string | null
): UncommittedChangesStrategyKind | null {
switch (strategy) {
case UncommittedChangesStrategyKind.AskForConfirmation:
return UncommittedChangesStrategyKind.AskForConfirmation
case UncommittedChangesStrategyKind.StashOnCurrentBranch:
return UncommittedChangesStrategyKind.StashOnCurrentBranch
case UncommittedChangesStrategyKind.MoveToNewBranch:
return UncommittedChangesStrategyKind.MoveToNewBranch
default:
return null
}
}
export const defaultUncommittedChangesStrategy: UncommittedChangesStrategy =
UncommittedChangesStrategy.AskForConfirmation

View file

@ -17,7 +17,10 @@ import { assertNever } from '../../lib/fatal-error'
import { ReleaseNotesUri } from '../lib/releases'
import { encodePathAsUrl } from '../../lib/path'
const DesktopLogo = encodePathAsUrl(__dirname, 'static/logo-64x64@2x.png')
const logoPath = __DARWIN__
? 'static/logo-64x64@2x.png'
: 'static/windows-logo-64x64@2x.png'
const DesktopLogo = encodePathAsUrl(__dirname, logoPath)
interface IAboutProps {
/**

View file

@ -197,16 +197,14 @@ export class AddExistingRepository extends React.Component<
private addRepository = async () => {
this.props.onDismissed()
const { dispatcher } = this.props
const resolvedPath = this.resolvedPath(this.state.path)
const repositories = await this.props.dispatcher.addRepositories([
resolvedPath,
])
const repositories = await dispatcher.addRepositories([resolvedPath])
if (repositories && repositories.length) {
const repository = repositories[0]
this.props.dispatcher.selectRepository(repository)
this.props.dispatcher.recordAddExistingRepository()
if (repositories.length > 0) {
dispatcher.selectRepository(repositories[0])
dispatcher.recordAddExistingRepository()
}
}

View file

@ -10,7 +10,6 @@ import {
getStatus,
getAuthorIdentity,
isGitRepository,
createAndCheckoutBranch,
} from '../../lib/git'
import { sanitizedRepositoryName } from './sanitized-repository-name'
import { TextBox } from '../lib/text-box'
@ -31,10 +30,6 @@ import { PopupType } from '../../models/popup'
import { Ref } from '../lib/ref'
import { enableReadmeOverwriteWarning } from '../../lib/feature-flag'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import {
getDefaultBranch,
DefaultBranchInGit,
} from '../../lib/helpers/default-branch'
/** The sentinel value used to indicate no gitignore should be used. */
const NoGitIgnoreValue = 'None'
@ -245,25 +240,6 @@ export class CreateRepository extends React.Component<
const repository = repositories[0]
const defaultBranch = await getDefaultBranch()
if (defaultBranch !== DefaultBranchInGit) {
try {
// Manually checkout to the configured default branch.
// TODO (git@2.28): Remove this code when upgrading to git v2.28
// since this will be natively implemented.
await createAndCheckoutBranch(repository, defaultBranch)
} catch (e) {
// When we cannot checkout the default branch just log the error,
// since we don't want to stop the repository creation (since we're
// in the middle of the creation process).
log.error(
`createRepository: unable to create default branch "${defaultBranch}"`,
e
)
}
}
if (this.state.createWithReadme) {
try {
await writeDefaultReadme(

View file

@ -108,16 +108,13 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
}
private renderErrorMessage(error: Error) {
const e = error instanceof ErrorWithMetadata ? error.underlyingError : error
const e = getUnderlyingError(error)
if (e instanceof GitError) {
// See getResultMessage in core.ts
// If the error message is the same as stderr or stdout then we know
// it's output from git and we'll display it in fixed-width font
if (e.message === e.result.stderr || e.message === e.result.stdout) {
const formattedMessage = this.formatGitErrorMessage(e.message)
return <p className="monospace">{formattedMessage}</p>
}
// If the error message is just the raw git output, display it in
// fixed-width font
if (isRawGitError(e)) {
const formattedMessage = this.formatGitErrorMessage(e.message)
return <p className="monospace">{formattedMessage}</p>
}
return <p>{e.message}</p>
@ -148,6 +145,9 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
onSubmit={this.onDismissed}
onDismissed={this.onDismissed}
disabled={this.state.disabled}
className={
isRawGitError(this.state.error) ? 'raw-git-error' : undefined
}
>
<DialogContent onRef={this.onDialogContentRef}>
{this.renderErrorMessage(error)}
@ -187,10 +187,8 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
const e = getUnderlyingError(this.state.error)
if (isGitError(e)) {
if (e.message === e.result.stderr || e.message === e.result.stdout) {
this.dialogContent.scrollTop = this.dialogContent.scrollHeight
}
if (isRawGitError(e)) {
this.dialogContent.scrollTop = this.dialogContent.scrollHeight
}
}
@ -285,6 +283,14 @@ function isGitError(error: Error): error is GitError {
return error instanceof GitError
}
function isRawGitError(error: Error | null) {
if (!error) {
return false
}
const e = getUnderlyingError(error)
return e instanceof GitError && e.isRawMessage
}
function isCloneError(error: Error) {
if (!isErrorWithMetaData(error)) {
return false

View file

@ -193,6 +193,11 @@ export class AppMenuBarButton extends React.Component<
dropdownState={dropDownState}
onDropdownStateChanged={this.onDropdownStateChanged}
dropdownContentRenderer={this.dropDownContentRenderer}
// Disable the dropdown focus trap for menus. Items in the menus are not
// "tabbable", so the app crashes when this prop is set to true and the
// user opens a menu (on Windows).
// Besides, we use a custom "focus trap" for menus anyway.
enableFocusTrap={false}
showDisclosureArrow={false}
onMouseEnter={this.onMouseEnter}
onKeyDown={this.onKeyDown}

View file

@ -42,7 +42,7 @@ import { TitleBar, ZoomInfo, FullScreenInfo } from './window'
import { RepositoriesList } from './repositories-list'
import { RepositoryView } from './repository'
import { RenameBranch } from './rename-branch'
import { DeleteBranch } from './delete-branch'
import { DeleteBranch, DeleteRemoteBranch } from './delete-branch'
import { CloningRepositoryView } from './cloning-repository'
import {
Toolbar,
@ -109,7 +109,6 @@ import { enableForkyCreateBranchUI } from '../lib/feature-flag'
import { ConfirmExitTutorial } from './tutorial'
import { TutorialStep, isValidTutorialStep } from '../models/tutorial-step'
import { WorkflowPushRejectedDialog } from './workflow-push-rejected/workflow-push-rejected'
import { getUncommittedChangesStrategy } from '../models/uncommitted-changes-strategy'
import { SAMLReauthRequiredDialog } from './saml-reauth-required/saml-reauth-required'
import { CreateForkDialog } from './forks/create-fork-dialog'
import { findDefaultUpstreamBranch } from '../lib/branch'
@ -120,6 +119,7 @@ import { ChooseForkSettings } from './choose-fork-settings'
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'
const MinuteInMilliseconds = 1000 * 60
const HourInMilliseconds = MinuteInMilliseconds * 60
@ -140,6 +140,7 @@ interface IAppProps {
readonly appStore: AppStore
readonly issuesStore: IssuesStore
readonly gitHubUserStore: GitHubUserStore
readonly aheadBehindStore: AheadBehindStore
readonly startTime: number
}
@ -989,39 +990,33 @@ export class App extends React.Component<IAppProps, IAppState> {
}
private async handleDragAndDrop(fileList: FileList) {
const paths: string[] = []
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i]
paths.push(file.path)
}
const paths = [...fileList].map(x => x.path)
const { dispatcher } = this.props
// If they're bulk adding repositories then just blindly try to add them.
// But if they just dragged one, use the dialog so that they can initialize
// it if needed.
if (paths.length > 1) {
const addedRepositories = await this.addRepositories(paths)
const addedRepositories = await dispatcher.addRepositories(paths)
if (addedRepositories.length > 0) {
this.props.dispatcher.recordAddExistingRepository()
dispatcher.recordAddExistingRepository()
await dispatcher.selectRepository(addedRepositories[0])
}
} else {
} else if (paths.length === 1) {
// user may accidentally provide a folder within the repository
// this ensures we use the repository root, if it is actually a repository
// otherwise we consider it an untracked repository
const first = paths[0]
const path = (await validatedRepositoryPath(first)) || first
const path = (await validatedRepositoryPath(first)) ?? first
const existingRepository = matchExistingRepository(
this.state.repositories,
path
)
const { repositories } = this.state
const existingRepository = matchExistingRepository(repositories, path)
if (existingRepository) {
await this.props.dispatcher.selectRepository(existingRepository)
await dispatcher.selectRepository(existingRepository)
} else {
await this.showPopup({
type: PopupType.AddRepository,
path,
})
await this.showPopup({ type: PopupType.AddRepository, path })
}
}
}
@ -1064,15 +1059,6 @@ export class App extends React.Component<IAppProps, IAppState> {
return state.repository
}
private async addRepositories(paths: ReadonlyArray<string>) {
const repositories = await this.props.dispatcher.addRepositories(paths)
if (repositories.length > 0) {
this.props.dispatcher.selectRepository(repositories[0])
}
return repositories
}
private showRebaseDialog() {
const repository = this.getRepository()
@ -1313,6 +1299,17 @@ export class App extends React.Component<IAppProps, IAppState> {
onDeleted={this.onBranchDeleted}
/>
)
case PopupType.DeleteRemoteBranch:
return (
<DeleteRemoteBranch
key="delete-remote-branch"
dispatcher={this.props.dispatcher}
repository={popup.repository}
branch={popup.branch}
onDismissed={onPopupDismissedFn}
onDeleted={this.onBranchDeleted}
/>
)
case PopupType.ConfirmDiscardChanges:
const showSetting =
popup.showDiscardChangesSetting === undefined
@ -1364,9 +1361,7 @@ export class App extends React.Component<IAppProps, IAppState> {
this.state.askForConfirmationOnDiscardChanges
}
confirmForcePush={this.state.askForConfirmationOnForcePush}
uncommittedChangesStrategyKind={
this.state.uncommittedChangesStrategyKind
}
uncommittedChangesStrategy={this.state.uncommittedChangesStrategy}
selectedExternalEditor={this.state.selectedExternalEditor}
optOutOfUsageTracking={this.state.optOutOfUsageTracking}
enterpriseAccount={this.getEnterpriseAccount()}
@ -1464,7 +1459,6 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.CreateBranch: {
const state = this.props.repositoryStateManager.get(popup.repository)
const branchesState = state.branchesState
const currentBranchProtected = state.changesState.currentBranchProtected
const repository = popup.repository
if (branchesState.tip.kind === TipState.Unknown) {
@ -1498,10 +1492,6 @@ export class App extends React.Component<IAppProps, IAppState> {
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
initialName={popup.initialName || ''}
currentBranchProtected={currentBranchProtected}
selectedUncommittedChangesStrategy={getUncommittedChangesStrategy(
this.state.uncommittedChangesStrategyKind
)}
/>
)
}
@ -1600,7 +1590,7 @@ export class App extends React.Component<IAppProps, IAppState> {
)
case PopupType.ExternalEditorFailed:
const openPreferences = popup.openPreferences
const suggestAtom = popup.suggestAtom
const suggestDefaultEditor = popup.suggestDefaultEditor
return (
<EditorError
@ -1609,7 +1599,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onDismissed={onPopupDismissedFn}
showPreferencesDialog={this.onShowAdvancedPreferences}
viewPreferences={openPreferences}
suggestAtom={suggestAtom}
suggestDefaultEditor={suggestDefaultEditor}
/>
)
case PopupType.OpenShellFailed:
@ -2333,8 +2323,8 @@ export class App extends React.Component<IAppProps, IAppState> {
const { aheadBehind, branchesState } = state
const { pullWithRebase, tip } = branchesState
if (tip.kind === TipState.Valid && tip.branch.remote !== null) {
remoteName = tip.branch.remote
if (tip.kind === TipState.Valid && tip.branch.upstreamRemoteName !== null) {
remoteName = tip.branch.upstreamRemoteName
}
const isForcePush = isCurrentBranchForcePush(branchesState, aheadBehind)
@ -2378,13 +2368,9 @@ export class App extends React.Component<IAppProps, IAppState> {
const repository = selection.repository
const state = this.props.repositoryStateManager.get(repository)
const currentBranchProtected = state.changesState.currentBranchProtected
return this.props.dispatcher.showPopup({
type: PopupType.CreateBranch,
repository,
currentBranchProtected,
})
}
@ -2434,9 +2420,7 @@ export class App extends React.Component<IAppProps, IAppState> {
currentFoldout !== null && currentFoldout.type === FoldoutType.Branch
const repository = selection.repository
const { branchesState, changesState } = selection.state
const hasAssociatedStash = changesState.stashEntry !== null
const hasChanges = changesState.workingDirectory.files.length > 0
const { branchesState } = selection.state
return (
<BranchDropdown
@ -2452,10 +2436,6 @@ export class App extends React.Component<IAppProps, IAppState> {
shouldNudge={
this.state.currentOnboardingTutorialStep === TutorialStep.CreateBranch
}
selectedUncommittedChangesStrategy={getUncommittedChangesStrategy(
this.state.uncommittedChangesStrategyKind
)}
couldOverwriteStash={hasChanges && hasAssociatedStash}
/>
)
}
@ -2588,6 +2568,8 @@ export class App extends React.Component<IAppProps, IAppState> {
onExitTutorial={this.onExitTutorial}
isShowingModal={this.isShowingModal}
isShowingFoldout={this.state.currentFoldout !== null}
aheadBehindStore={this.props.aheadBehindStore}
commitSpellcheckEnabled={this.state.commitSpellcheckEnabled}
/>
)
} else if (selectedState.type === SelectionType.CloningRepository) {

View file

@ -36,6 +36,9 @@ interface IAutocompletingTextInputProps<ElementType> {
/** Indicates if input field should be required */
readonly isRequired?: boolean
/** Indicates if input field applies spellcheck */
readonly spellcheck?: boolean
/**
* Called when the user changes the value in the input field.
*/
@ -281,6 +284,7 @@ export abstract class AutocompletingTextInput<
onContextMenu: this.onContextMenu,
disabled: this.props.disabled,
'aria-required': this.props.isRequired ? true : false,
spellCheck: this.props.spellcheck,
}
return React.createElement<React.HTMLAttributes<ElementType>, ElementType>(

View file

@ -5,6 +5,8 @@ import { IMatches } from '../../lib/fuzzy-find'
import { Octicon, OcticonSymbol } from '../octicons'
import { HighlightText } from '../lib/highlight-text'
import { showContextualMenu } from '../main-process-proxy'
import { IMenuItem } from '../../lib/menu-item'
interface IBranchListItemProps {
/** The name of the branch */
@ -18,10 +20,51 @@ interface IBranchListItemProps {
/** The characters in the branch name to highlight */
readonly matches: IMatches
/** Specifies whether the branch is local */
readonly isLocal: boolean
readonly onRenameBranch?: (branchName: string) => void
readonly onDeleteBranch?: (branchName: string) => void
}
/** The branch component. */
export class BranchListItem extends React.Component<IBranchListItemProps, {}> {
private onContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
/*
There are multiple instances in the application where a branch list item
is rendered. We only want to be able to rename or delete them on the
branch dropdown menu. Thus, other places simply will not provide these
methods, such as the merge and rebase logic.
*/
const { onRenameBranch, onDeleteBranch, name, isLocal } = this.props
if (onRenameBranch === undefined && onDeleteBranch === undefined) {
return
}
const items: Array<IMenuItem> = []
if (onRenameBranch !== undefined) {
items.push({
label: 'Rename…',
action: () => onRenameBranch(name),
enabled: isLocal,
})
}
if (onDeleteBranch !== undefined) {
items.push({
label: 'Delete…',
action: () => onDeleteBranch(name),
})
}
showContextualMenu(items)
}
public render() {
const lastCommitDate = this.props.lastCommitDate
const isCurrentBranch = this.props.isCurrentBranch
@ -35,7 +78,7 @@ export class BranchListItem extends React.Component<IBranchListItemProps, {}> {
? lastCommitDate.toString()
: ''
return (
<div className="branches-list-item">
<div onContextMenu={this.onContextMenu} className="branches-list-item">
<Octicon className="icon" symbol={icon} />
<div className="name" title={name}>
<HighlightText text={name} highlight={this.props.matches.title} />

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import { Branch } from '../../models/branch'
import { Branch, BranchType } from '../../models/branch'
import { IBranchListItem } from './group-branches'
import { BranchListItem } from './branch-list-item'
@ -9,7 +9,9 @@ import { IMatches } from '../../lib/fuzzy-find'
export function renderDefaultBranch(
item: IBranchListItem,
matches: IMatches,
currentBranch: Branch | null
currentBranch: Branch | null,
onRenameBranch?: (branchName: string) => void,
onDeleteBranch?: (branchName: string) => void
): JSX.Element {
const branch = item.branch
const commit = branch.tip
@ -18,8 +20,11 @@ export function renderDefaultBranch(
<BranchListItem
name={branch.name}
isCurrentBranch={branch.name === currentBranchName}
isLocal={branch.type === BranchType.Local}
lastCommitDate={commit ? commit.author.date : null}
matches={matches}
onRenameBranch={onRenameBranch}
onDeleteBranch={onDeleteBranch}
/>
)
}

Some files were not shown because too many files have changed in this diff Show more