mirror of
https://github.com/desktop/desktop
synced 2024-09-12 21:01:16 +00:00
Merge remote-tracking branch 'upstream/development' into windows-arm-support
This commit is contained in:
commit
a452402fd1
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
@ -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
|
||||
|
|
|
@ -1 +1 @@
|
|||
12.14.1
|
||||
14.15.1
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
python 2.7.16
|
||||
nodejs 12.14.1
|
||||
nodejs 14.15.1
|
||||
|
|
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
runtime = electron
|
||||
disturl = https://atom.io/download/electron
|
||||
target = 9.3.1
|
||||
target = 11.1.1
|
||||
|
|
|
@ -16,5 +16,7 @@ export function getVersion() {
|
|||
}
|
||||
|
||||
export function getBundleID() {
|
||||
return appPackage.bundleID
|
||||
return process.env.NODE_ENV === 'development'
|
||||
? `${appPackage.bundleID}Dev`
|
||||
: appPackage.bundleID
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
//
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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}`))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
export * from './lookup'
|
||||
export * from './launch'
|
||||
export { ExternalEditor, parse } from './shared'
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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
|
||||
|
|
|
@ -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
10
app/src/lib/enum.ts
Normal 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)
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
47
app/src/lib/git/cherry-pick.ts
Normal file
47
app/src/lib/git/cherry-pick.ts
Normal 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)}'`)
|
||||
}
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
93
app/src/lib/git/git-delimiter-parser.ts
Normal file
93
app/src/lib/git/git-delimiter-parser.ts
Normal 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 }
|
||||
}
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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] }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
134
app/src/lib/stores/ahead-behind-store.ts
Normal file
134
app/src/lib/stores/ahead-behind-store.ts
Normal 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
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
32
app/src/lib/stores/helpers/find-forked-remotes-to-prune.ts
Normal file
32
app/src/lib/stores/helpers/find-forked-remotes-to-prune.ts
Normal 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)
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 */
|
||||
|
|
|
@ -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`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) || []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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}`)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
17
app/src/models/equality-hash.ts
Normal file
17
app/src/models/equality-hash.ts
Normal 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('+')
|
||||
}
|
|
@ -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
|
||||
}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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"?
|
||||
|
|
|
@ -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
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue