Merge branch 'development' into dependency-version-lag-docs

This commit is contained in:
Brendan Forster 2019-07-29 11:30:47 -03:00 committed by GitHub
commit 61cea247d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
269 changed files with 10964 additions and 4324 deletions

View file

@ -3,7 +3,7 @@ version: 2
defaults: &defaults
working_directory: ~/desktop/desktop
macos:
xcode: '9.3.0'
xcode: '9.4.1'
jobs:
build:

View file

@ -6,6 +6,10 @@ plugins:
- react
- json
settings:
react:
version: '16.3'
extends:
- prettier
- prettier/react
@ -36,8 +40,6 @@ rules:
# this rule now works but generates a lot of issues with the codebase
# '@typescript-eslint/member-ordering': error
'@typescript-eslint/type-annotation-spacing': error
# Babel
babel/no-invalid-this: error
@ -70,6 +72,7 @@ rules:
strict:
- error
- global
no-buffer-constructor: error
###########
# SPECIAL #

View file

@ -64,8 +64,8 @@ https://github.com/desktop/desktop/blob/development/docs/contributing/timeline-p
<!--
Attach your log file (You can simply drag your file here to insert it) to this issue. Please make sure the generated link to your log file is **below** this comment section otherwise it will not appear when you submit your issue.
macOS logs location: `~/Library/Application Support/GitHub Desktop/logs/*.desktop.production.log`
Windows logs location: `%APPDATA%\GitHub Desktop\logs\*.desktop.production.log`
macOS logs accessible from the Help menu or location: `~/Library/Application Support/GitHub Desktop/logs/*.desktop.production.log`
Windows logs accessible from the Help menu or location: `%APPDATA%\GitHub Desktop\logs\*.desktop.production.log`
The log files are organized by date, so see if anything was generated for today's date.
-->

View file

@ -4,6 +4,15 @@ about: Surface a problem that you think should be solved
---
<!--
First and foremost, wed like to thank you for taking the time to contribute to our project. Before submitting your issue, please follow these steps:
1. Familiarize yourself with our contributing guide:
* https://github.com/desktop/desktop/blob/development/.github/CONTRIBUTING.md#contributing-to-github-desktop
2. Make sure your issue isnt a duplicate of another issue
3. If you have made it to this step, go ahead and fill out the template below
-->
**Please describe the problem you think should be solved**
A clear and concise description of what the problem is and who else might be impacted. Screenshots are encouraged.

2
.github/config.yml vendored
View file

@ -13,7 +13,7 @@ requestInfoReplyComment: >
Thanks for understanding and meeting us halfway 😀
requestInfoLabelToAdd: more-information-needed
requestInfoLabelToAdd: more-info-needed
requestInfoOn:
pullRequest: false

View file

@ -1 +1 @@
8.12.0
10.16.0

2
.nvmrc
View file

@ -1 +1 @@
v8.12.0
v10

View file

@ -12,5 +12,4 @@ app/coverage
app/static/common
app/test/fixtures
gemoji
*.json
*.md

View file

@ -1,2 +1,2 @@
python 2.7
nodejs 8.12.0
python 2.7.16
nodejs 10.15.3

View file

@ -23,11 +23,12 @@ addons:
branches:
only:
- development
- /releases\/.+/
- /^__release-.*/
language: node_js
node_js:
- '8.12'
- '10'
cache:
yarn: true

View file

@ -2,8 +2,7 @@
"recommendations": [
"ms-vscode.vscode-typescript-tslint-plugin",
"msjsdiag.debugger-for-chrome",
"samverschueren.final-newline",
"DmitryDorofeev.empty-indent",
"esbenp.prettier-vscode"
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
]
}

10
.vscode/launch.json vendored
View file

@ -14,12 +14,12 @@
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"env":{
"env": {
"ELECTRON_RUN_AS_NODE": "1"
},
"windows": {
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
},
}
},
{
"type": "node",
@ -35,12 +35,12 @@
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"env":{
"env": {
"ELECTRON_RUN_AS_NODE": "1"
},
"windows": {
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
},
},
}
}
]
}

12
.vscode/settings.json vendored
View file

@ -24,5 +24,15 @@
"prettier.trailingComma": "es5",
"editor.formatOnSave": true,
"prettier.ignorePath": ".prettierignore",
"tslint.ignoreDefinitionFiles": true
"tslint.ignoreDefinitionFiles": true,
"eslint.options": {
"configFile": ".eslintrc.yml",
"rulePaths": ["eslint-rules"]
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
]
}

View file

@ -21,13 +21,6 @@ Download the official installer for your operating system:
- [Windows](https://central.github.com/deployments/desktop/desktop/latest/win32)
- [Windows machine-wide install](https://central.github.com/deployments/desktop/desktop/latest/win32?format=msi)
There are several community-supported package managers that can be used to 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`
- Arch Linux users can install the latest version from the [AUR](https://aur.archlinux.org/packages/github-desktop/).
You can install this alongside your existing GitHub Desktop for Mac or GitHub
Desktop for Windows application.
@ -43,6 +36,21 @@ 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)
### Community Releases
There are several community-supported package managers that can be used to
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`
Installers for various Linux distributions can be found on the
[`shiftkey/desktop`](https://github.com/shiftkey/desktop) fork.
Arch Linux users can install the latest version from the
[AUR](https://aur.archlinux.org/packages/github-desktop-bin/).
## Is GitHub Desktop right for me? What are the primary areas of focus?
[This document](https://github.com/desktop/desktop/blob/development/docs/process/what-is-desktop.md) describes the focus of GitHub Desktop and who the product is most useful for.

View file

@ -1,4 +1,4 @@
runtime = electron
disturl = https://atom.io/download/electron
target = 3.1.6
target = 5.0.6
arch = x64

View file

@ -3,7 +3,7 @@
"productName": "GitHub Desktop",
"bundleID": "com.github.GitHubClient",
"companyName": "GitHub, Inc.",
"version": "1.7.0-beta1",
"version": "2.1.1-beta2",
"main": "./main.js",
"repository": {
"type": "git",
@ -27,14 +27,15 @@
"dexie": "^2.0.0",
"double-ended-queue": "^2.1.0-0",
"dugite": "1.87.0",
"electron-window-state": "^4.0.2",
"electron-window-state": "^5.0.3",
"event-kit": "^2.0.0",
"file-uri-to-path": "0.0.2",
"file-url": "^2.0.2",
"fs-admin": "^0.3.0",
"fs-admin": "^0.3.1",
"fs-extra": "^6.0.0",
"fuzzaldrin-plus": "^0.6.0",
"keytar": "^4.4.1",
"mem": "^4.3.0",
"memoize-one": "^4.0.3",
"moment": "^2.24.0",
"mri": "^1.1.0",
@ -48,7 +49,7 @@
"react-dom": "^16.3.2",
"react-transition-group": "^1.2.0",
"react-virtualized": "^9.20.0",
"registry-js": "^1.0.7",
"registry-js": "^1.4.0",
"source-map-support": "^0.4.15",
"strip-ansi": "^4.0.0",
"textarea-caret": "^3.0.2",

View file

@ -1,7 +1,9 @@
import chalk from 'chalk'
import * as Path from 'path'
import { ICommandModule, mriArgv } from '../load-commands'
import { openDesktop } from '../open-desktop'
import { parseRemote } from '../../lib/remote-parsing'
const command: ICommandModule = {
command: 'open <path>',
@ -21,9 +23,18 @@ const command: ICommandModule = {
openDesktop()
return
}
const repositoryPath = Path.resolve(process.cwd(), pathArg)
const url = `openLocalRepo/${encodeURIComponent(repositoryPath)}`
openDesktop(url)
//Check if the pathArg is a remote url
if (parseRemote(pathArg) != null) {
console.log(
`\nYou cannot open a remote URL in GitHub Desktop\n` +
`Use \`${chalk.bold(`git clone ` + pathArg)}\`` +
` instead to initiate the clone`
)
} else {
const repositoryPath = Path.resolve(process.cwd(), pathArg)
const url = `openLocalRepo/${encodeURIComponent(repositoryPath)}`
openDesktop(url)
}
},
}
export = command

View file

@ -113,6 +113,7 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
'.vcxproj': 'text/xml',
'.vbproj': 'text/xml',
'.svg': 'text/xml',
'.resx': 'text/xml',
},
},
{
@ -265,6 +266,126 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
'.jl': 'text/x-julia',
},
},
{
install: () => import('codemirror/mode/stex/stex'),
mappings: {
'.tex': 'text/x-stex',
},
},
{
install: () => import('codemirror/mode/sparql/sparql'),
mappings: {
'.rq': 'application/sparql-query',
},
},
{
install: () => import('codemirror/mode/stylus/stylus'),
mappings: {
'.styl': 'text/x-styl',
},
},
{
install: () => import('codemirror/mode/soy/soy'),
mappings: {
'.soy': 'text/x-soy',
},
},
{
install: () => import('codemirror/mode/smalltalk/smalltalk'),
mappings: {
'.st': 'text/x-stsrc',
},
},
{
install: () => import('codemirror/mode/slim/slim'),
mappings: {
'.slim': 'application/x-slim',
},
},
{
install: () => import('codemirror/mode/sieve/sieve'),
mappings: {
'.sieve': 'application/sieve',
},
},
{
install: () => import('codemirror/mode/scheme/scheme'),
mappings: {
'.ss': 'text/x-scheme',
'.sls': 'text/x-scheme',
'.scm': 'text/x-scheme',
},
},
{
install: () => import('codemirror/mode/rst/rst'),
mappings: {
'.rst': 'text/x-rst',
},
},
{
install: () => import('codemirror/mode/rpm/rpm'),
mappings: {
'.rpm': 'text/x-rpm-spec',
},
},
{
install: () => import('codemirror/mode/q/q'),
mappings: {
'.q': 'text/x-q',
},
},
{
install: () => import('codemirror/mode/puppet/puppet'),
mappings: {
'.pp': 'text/x-puppet',
},
},
{
install: () => import('codemirror/mode/pug/pug'),
mappings: {
'.pug': 'text/x-pug',
},
},
{
install: () => import('codemirror/mode/protobuf/protobuf'),
mappings: {
'.proto': 'text/x-protobuf',
},
},
{
install: () => import('codemirror/mode/properties/properties'),
mappings: {
'.properties': 'text/x-properties',
'.gitattributes': 'text/x-properties',
'.gitignore': 'text/x-properties',
'.editorconfig': 'text/x-properties',
'.ini': 'text/x-ini',
},
},
{
install: () => import('codemirror/mode/pig/pig'),
mappings: {
'.pig': 'text/x-pig',
},
},
{
install: () => import('codemirror/mode/asciiarmor/asciiarmor'),
mappings: {
'.pgp': 'application/pgp',
},
},
{
install: () => import('codemirror/mode/oz/oz'),
mappings: {
'.oz': 'text/x-oz',
},
},
{
install: () => import('codemirror/mode/pascal/pascal'),
mappings: {
'.pas': 'text/x-pascal',
},
},
]
/**

View file

@ -14,6 +14,50 @@ import { uuid } from './uuid'
import { getAvatarWithEnterpriseFallback } from './gravatar'
import { getDefaultEmail } from './email'
/**
* Optional set of configurable settings for the fetchAll method
*/
interface IFetchAllOptions<T> {
/**
* The number of results to ask for on each page when making
* requests to paged API endpoints.
*/
perPage?: number
/**
* An optional predicate which determines whether or not to
* continue loading results from the API. This can be used
* to put a limit on the number of results to return from
* a paged API resource.
*
* As an example, to stop loading results after 500 results:
*
* `(results) => results.length < 500`
*
* @param results All results retrieved thus far
*/
continue?: (results: ReadonlyArray<T>) => boolean
/**
* Calculate the next page path given the response.
*
* Optional, see `getNextPagePathFromLink` for the default
* implementation.
*/
getNextPagePath?: (response: Response) => string | null
/**
* Whether or not to silently suppress request errors and
* return the results retrieved thus far. If this field is
* `true` the fetchAll method will suppress errors (this is
* also the default behavior if no value is provided for
* this field). Setting this field to false will cause the
* fetchAll method to throw if it encounters an API error
* on any page.
*/
suppressErrors?: boolean
}
const username: () => Promise<string> = require('username')
const ClientID = process.env.TEST_ENV ? '' : __OAUTH_CLIENT_ID__
@ -129,6 +173,12 @@ export interface IAPIMentionableUser {
readonly name: string
}
/**
* Error thrown by `fetchUpdatedPullRequests` when receiving more results than
* what the `maxResults` parameter allows for.
*/
export class MaxResultsError extends Error {}
/**
* `null` can be returned by the API for legacy reasons. A non-null value is
* set for the primary email address currently, but in the future visibility
@ -176,6 +226,23 @@ export interface IAPIRefStatus {
readonly statuses: ReadonlyArray<IAPIRefStatusItem>
}
/** Branch information returned by the GitHub API */
export interface IAPIBranch {
/**
* The name of the branch stored on the remote.
*
* NOTE: this is NOT a fully-qualified ref (i.e. `refs/heads/master`)
*/
readonly name: string
/**
* Branch protection settings:
*
* - `true` indicates that the branch is protected in some way
* - `false` indicates no branch protection set
*/
readonly protected: boolean
}
interface IAPIPullRequestRef {
readonly ref: string
readonly sha: string
@ -192,9 +259,11 @@ export interface IAPIPullRequest {
readonly number: number
readonly title: string
readonly created_at: string
readonly updated_at: string
readonly user: IAPIIdentity
readonly head: IAPIPullRequestRef
readonly base: IAPIPullRequestRef
readonly state: 'open' | 'closed'
}
/** The metadata about a GitHub server. */
@ -235,7 +304,7 @@ interface ISearchResults<T> {
*
* If no link rel next header is found this method returns null.
*/
function getNextPagePath(response: Response): string | null {
function getNextPagePathFromLink(response: Response): string | null {
const linkHeader = response.headers.get('Link')
if (!linkHeader) {
@ -255,6 +324,91 @@ function getNextPagePath(response: Response): string | null {
return null
}
/**
* Parses the 'next' Link header from GitHub using
* `getNextPagePathFromLink`. Unlike `getNextPagePathFromLink`
* this method will attempt to double the page size when
* the current page index and the page size allows for it
* leading to a ramp up in page size.
*
* This might sound confusing, and it is, but the primary use
* case for this is when retrieving updated PRs. By specifying
* an initial page size of, for example, 10 this method will
* increase the page size to 20 once the second page has been
* loaded. See the table below for an example. The ramp-up
* will stop at a page size of 100 since that's the maximum
* that the GitHub API supports.
*
* ```
* |-----------|------|-----------|-----------------|
* | Request # | Page | Page size | Retrieved items |
* |-----------|------|-----------|-----------------|
* | 1 | 1 | 10 | 10 |
* | 2 | 2 | 10 | 20 |
* | 3 | 2 | 20 | 40 |
* | 4 | 2 | 40 | 80 |
* | 5 | 2 | 80 | 160 |
* | 6 | 3 | 80 | 240 |
* | 7 | 4 | 80 | 320 |
* | 8 | 5 | 80 | 400 |
* | 9 | 5 | 100 | 500 |
* |-----------|------|-----------|-----------------|
* ```
* This algorithm means we can have the best of both worlds.
* If there's a small number of changed pull requests since
* our last update we'll do small requests that use minimal
* bandwidth but if we encounter a repository where a lot
* of PRs have changed since our last fetch (like a very
* active repository or one we haven't fetched in a long time)
* we'll spool up our page size in just a few requests and load
* in bulk.
*
* As an example I used a very active internal repository and
* asked for all PRs updated in the last 24 hours which was 320.
* With the previous regime of fetching with a page size of 10
* that obviously took 32 requests. With this new regime it
* would take 7.
*/
export function getNextPagePathWithIncreasingPageSize(response: Response) {
const nextPath = getNextPagePathFromLink(response)
if (!nextPath) {
return null
}
const { pathname, query } = URL.parse(nextPath, true)
const { per_page, page } = query
const pageSize = typeof per_page === 'string' ? parseInt(per_page, 10) : NaN
const pageNumber = typeof page === 'string' ? parseInt(page, 10) : NaN
if (!pageSize || !pageNumber) {
return nextPath
}
// Confusing, but we're looking at the _next_ page path here
// so the current is whatever came before it.
const currentPage = pageNumber - 1
// Number of received items thus far
const received = currentPage * pageSize
// Can't go above 100, that's the max the API will allow.
const nextPageSize = Math.min(100, pageSize * 2)
// Have we received exactly the amount of items
// such that doubling the page size and loading the
// second page would seamlessly fit? No sense going
// above 100 since that's the max the API supports
if (pageSize !== nextPageSize && received % nextPageSize === 0) {
query.per_page = `${nextPageSize}`
query.page = `${received / nextPageSize + 1}`
return URL.format({ pathname, query })
}
return nextPath
}
/**
* Returns an ISO 8601 time string with second resolution instead of
* the standard javascript toISOString which returns millisecond
@ -419,7 +573,7 @@ export class API {
throw new Error(
`Unable to create repository for organization '${
org.login
}'. Verify it exists and that you have permission to create a repository there.`
}'. Verify that it exists, that it's a paid organization, and that you have permission to create a repository there.`
)
}
throw e
@ -461,18 +615,84 @@ export class API {
}
}
/** Fetch the pull requests in the given repository. */
public async fetchPullRequests(
/** Fetch all open pull requests in the given repository. */
public async fetchAllOpenPullRequests(owner: string, name: string) {
const url = urlWithQueryString(`repos/${owner}/${name}/pulls`, {
state: 'open',
})
try {
return await this.fetchAll<IAPIPullRequest>(url)
} catch (e) {
log.warn(`failed fetching open PRs for repository ${owner}/${name}`, e)
throw e
}
}
/**
* Fetch all pull requests in the given repository that have been
* updated on or after the provided date.
*
* Note: The GitHub API doesn't support providing a last-updated
* limitation for PRs like it does for issues so we're emulating
* the issues API by sorting PRs descending by last updated and
* only grab as many pages as we need to until we no longer receive
* PRs that have been update more recently than the `since`
* parameter.
*
* If there's more than `maxResults` updated PRs since the last time
* we fetched this method will throw an error such that we can abort
* this strategy and commence loading all open PRs instead.
*/
public async fetchUpdatedPullRequests(
owner: string,
name: string,
state: 'open' | 'closed' | 'all'
): Promise<ReadonlyArray<IAPIPullRequest>> {
const url = urlWithQueryString(`repos/${owner}/${name}/pulls`, { state })
since: Date,
// 320 is chosen because with a ramp-up page size starting with
// a page size of 10 we'll reach 320 in exactly 7 pages. See
// getNextPagePathWithIncreasingPageSize
maxResults = 320
) {
const sinceTime = since.getTime()
const url = urlWithQueryString(`repos/${owner}/${name}/pulls`, {
state: 'all',
sort: 'updated',
direction: 'desc',
})
try {
const prs = await this.fetchAll<IAPIPullRequest>(url)
return prs
const prs = await this.fetchAll<IAPIPullRequest>(url, {
// We use a page size smaller than our default 100 here because we
// expect that the majority use case will return much less than
// 100 results. Given that as long as _any_ PR has changed we'll
// get the full list back (PRs doesn't support ?since=) we want
// to keep this number fairly conservative in order to not use
// up bandwidth needlessly while balancing it such that we don't
// have to use a lot of requests to update our database. We then
// ramp up the page size (see getNextPagePathWithIncreasingPageSize)
// if it turns out there's a lot of updated PRs.
perPage: 10,
getNextPagePath: getNextPagePathWithIncreasingPageSize,
continue(results) {
if (results.length >= maxResults) {
throw new MaxResultsError('got max pull requests, aborting')
}
// Given that we sort the results in descending order by their
// updated_at field we can safely say that if the last item
// is modified after our sinceTime then haven't reached the
// end of updated PRs.
const last = results[results.length - 1]
return last !== undefined && Date.parse(last.updated_at) > sinceTime
},
// We can't ignore errors here as that might mean that we haven't
// retrieved enough pages to fully capture the changes since the
// last time we updated. Ignoring errors here would mean that we'd
// store an incorrect lastUpdated field in the database.
suppressErrors: false,
})
return prs.filter(pr => Date.parse(pr.updated_at) >= sinceTime)
} catch (e) {
log.warn(`fetchPullRequests: failed for repository ${owner}/${name}`, e)
log.warn(`failed fetching updated PRs for repository ${owner}/${name}`, e)
throw e
}
}
@ -493,6 +713,23 @@ export class API {
return await parsedResponse<IAPIRefStatus>(response)
}
public async fetchProtectedBranches(
owner: string,
name: string
): Promise<ReadonlyArray<IAPIBranch>> {
const path = `repos/${owner}/${name}/branches?protected=true`
try {
const response = await this.request('GET', path)
return await parsedResponse<IAPIBranch[]>(response)
} catch (err) {
log.info(
`[fetchProtectedBranches] unable to list protected branches`,
err
)
return new Array<IAPIBranch>()
}
}
/**
* Authenticated requests to a paginating resource such as issues.
*
@ -500,31 +737,28 @@ export class API {
* pages when available, buffers all items and returns them in
* one array when done.
*/
private async fetchAll<T>(path: string): Promise<ReadonlyArray<T>> {
private async fetchAll<T>(path: string, options?: IFetchAllOptions<T>) {
const buf = new Array<T>()
const opts: IFetchAllOptions<T> = { perPage: 100, ...options }
const params = { per_page: `${opts.perPage}` }
const params = {
per_page: '100',
}
let nextPath: string | null = urlWithQueryString(path, params)
do {
const response = await this.request('GET', nextPath)
if (response.status === HttpStatusCode.NotFound) {
log.warn(`fetchAll: '${path}' returned a 404`)
return []
}
if (response.status === HttpStatusCode.NotModified) {
log.warn(`fetchAll: '${path}' returned a 304`)
return []
const response: Response = await this.request('GET', nextPath)
if (opts.suppressErrors !== false && !response.ok) {
log.warn(`fetchAll: '${path}' returned a ${response.status}`)
return buf
}
const items = await parsedResponse<ReadonlyArray<T>>(response)
if (items) {
buf.push(...items)
}
nextPath = getNextPagePath(response)
} while (nextPath)
nextPath = opts.getNextPagePath
? opts.getNextPagePath(response)
: getNextPagePathFromLink(response)
} while (nextPath && (!opts.continue || opts.continue(buf)))
return buf
}
@ -844,7 +1078,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, it lives on the same domain but without the
// Whereas with Enterprise Server, it lives on the same domain but without the
// API path:
// E.g., https://github.mycompany.com/api/v3 -> https://github.mycompany.com
//

View file

@ -4,7 +4,7 @@ import { IDiff, ImageDiffType } from '../models/diff'
import { Repository, ILocalRepositoryState } from '../models/repository'
import { Branch, IAheadBehind } from '../models/branch'
import { Tip } from '../models/tip'
import { Commit } from '../models/commit'
import { Commit, CommitOneLine } from '../models/commit'
import { CommittedFileChange, WorkingDirectoryStatus } from '../models/status'
import { CloningRepository } from '../models/cloning-repository'
import { IMenu } from '../models/app-menu'
@ -35,6 +35,9 @@ import { ApplicationTheme } from '../ui/lib/application-theme'
import { IAccountRepositories } from './stores/api-repositories-store'
import { ManualConflictResolution } from '../models/manual-conflict-resolution'
import { Banner } from '../models/banner'
import { GitRebaseProgress } from '../models/rebase'
import { RebaseFlowStep } from '../models/rebase-flow-step'
import { IStashEntry } from '../models/stash-entry'
export enum SelectionType {
Repository,
@ -149,6 +152,9 @@ export interface IAppState {
/** The width of the commit summary column in the history view */
readonly commitSummaryWidth: number
/** The width of the files list in the stash view */
readonly stashedFilesWidth: number
/** Whether we should hide the toolbar (and show inverted window controls) */
readonly titleBarStyle: 'light' | 'dark'
@ -173,6 +179,8 @@ export interface IAppState {
/** The external editor to use when opening repositories */
readonly selectedExternalEditor?: ExternalEditor
/** The current setting for whether the user has disable usage reports */
readonly optOutOfUsageTracking: boolean
/**
* A cached entry representing an external editor found on the user's machine:
*
@ -248,10 +256,20 @@ export type AppMenuFoldout = {
openedWithAccessKey?: boolean
}
export type BranchFoldout = {
type: FoldoutType.Branch
/**
* A flag to indicate the user clicked the "switch branch" link when they
* saw the prompt about the current branch being protected.
*/
handleProtectedBranchWarning?: boolean
}
export type Foldout =
| { type: FoldoutType.Repository }
| { type: FoldoutType.Branch }
| { type: FoldoutType.AddMenu }
| BranchFoldout
| AppMenuFoldout
export enum RepositorySectionTab {
@ -341,6 +359,8 @@ export interface IRepositoryState {
readonly branchesState: IBranchesState
readonly rebaseState: IRebaseState
/**
* Mapping from lowercased email addresses to the associated GitHub user. Note
* that an email address may not have an associated GitHub user, or the user
@ -396,12 +416,6 @@ export interface IRepositoryState {
* null if no such operation is in flight.
*/
readonly revertProgress: IRevertProgress | null
/** The current branch filter text. */
readonly branchFilterText: string
/** The current pull request filter text. */
readonly pullRequestFilterText: string
}
export interface IBranchesState {
@ -455,6 +469,39 @@ export interface IBranchesState {
readonly rebasedBranches: ReadonlyMap<string, string>
}
/** State associated with a rebase being performed on a repository */
export interface IRebaseState {
/**
* The current step of the flow the user should see.
*
* `null` indicates that there is no rebase underway.
*/
readonly step: RebaseFlowStep | null
/**
* The underlying Git information associated with the current rebase
*
* This will be set to `null` when no base branch has been selected to
* initiate the rebase.
*/
readonly progress: GitRebaseProgress | null
/**
* The known range of commits that will be applied to the repository
*
* This will be set to `null` when no base branch has been selected to
* initiate the rebase.
*/
readonly commits: ReadonlyArray<CommitOneLine> | null
/**
* Whether the user has done work to resolve any conflicts as part of this
* rebase, as the rebase flow should confirm the user wishes to abort the
* rebase and lose that work.
*/
readonly userHasResolvedConflicts: boolean
}
export interface ICommitSelection {
/** The commit currently selected in the app */
readonly sha: string | null
@ -469,16 +516,38 @@ export interface ICommitSelection {
readonly diff: IDiff | null
}
export interface IChangesState {
readonly workingDirectory: WorkingDirectoryStatus
export enum ChangesSelectionKind {
WorkingDirectory = 'WorkingDirectory',
Stash = 'Stash',
}
export type ChangesWorkingDirectorySelection = {
readonly kind: ChangesSelectionKind.WorkingDirectory
/**
* The ID of the selected files. The files themselves can be looked up in
* `workingDirectory`.
* the `workingDirectory` property in `IChangesState`.
*/
readonly selectedFileIDs: string[]
readonly diff: IDiff | null
}
export type ChangesStashSelection = {
readonly kind: ChangesSelectionKind.Stash
/** Currently selected file in the stash diff viewer UI (aka the file we want to show the diff for) */
readonly selectedStashedFile: CommittedFileChange | null
/** Currently selected file's diff */
readonly selectedStashedFileDiff: IDiff | null
}
export type ChangesSelection =
| ChangesWorkingDirectorySelection
| ChangesStashSelection
export interface IChangesState {
readonly workingDirectory: WorkingDirectoryStatus
/** The commit message for a work-in-progress commit in the changes view. */
readonly commitMessage: ICommitMessage
@ -504,6 +573,23 @@ export interface IChangesState {
* The absence of a value means there is no merge or rebase conflict underway
*/
readonly conflictState: ConflictState | null
/**
* The latest GitHub Desktop stash entry for the current branch, or `null`
* if no stash exists for the current branch.
*/
readonly stashEntry: IStashEntry | null
/**
* The current selection state in the Changes view. Can be either
* working directory or a stash. In the case of a working directory
* selection multiple files may be selected. See `ChangesSelection`
* for more information about the differences between the two.
*/
readonly selection: ChangesSelection
/** `true` if the GitHub API reports that the branch is protected */
readonly currentBranchProtected: boolean
}
/**

View file

@ -1,13 +1,14 @@
import Dexie from 'dexie'
import { APIRefState, IAPIRefStatusItem } from '../api'
import { BaseDatabase } from './base-database'
import { GitHubRepository } from '../../models/github-repository'
import { fatalError, forceUnwrap } from '../fatal-error'
export interface IPullRequestRef {
/**
* The database ID of the GitHub repository in which this ref lives. It could
* be null if the repository was deleted on the site after the PR was opened.
*/
readonly repoId: number | null
readonly repoId: number
/** The name of the ref. */
readonly ref: string
@ -17,12 +18,6 @@ export interface IPullRequestRef {
}
export interface IPullRequest {
/**
* The database ID. This will be undefined if the pull request hasn't been
* inserted into the DB.
*/
readonly id?: number
/** The GitHub PR number. */
readonly number: number
@ -32,6 +27,9 @@ export interface IPullRequest {
/** The string formatted date on which the PR was created. */
readonly createdAt: string
/** The string formatted date on which the PR was created. */
readonly updatedAt: string
/** The ref from which the pull request's changes are coming. */
readonly head: IPullRequestRef
@ -42,35 +40,38 @@ export interface IPullRequest {
readonly author: string
}
export interface IPullRequestStatus {
/**
* Interface describing a record in the
* pullRequestsLastUpdated table.
*/
interface IPullRequestsLastUpdated {
/**
* The database ID. This will be undefined if the status hasn't been inserted
* into the DB.
* The primary key. Corresponds to the
* dbId property for the associated `GitHubRepository`
* instance.
*/
readonly id?: number
/** The ID of the pull request in the database. */
readonly pullRequestId: number
/** The status' state. */
readonly state: APIRefState
/** The number of statuses represented in this combined status. */
readonly totalCount: number
/** The SHA for which this status applies. */
readonly sha: string
readonly repoId: number
/**
* The list of statuses for this specific ref or undefined
* if the database object was created prior to status support
* being added in #3588
* The maximum value of the updated_at field on a
* pull request that we've seen in milliseconds since
* the epoch.
*/
readonly statuses?: ReadonlyArray<IAPIRefStatusItem>
readonly lastUpdated: number
}
/**
* Pull Requests are keyed on the ID of the GitHubRepository
* that they belong to _and_ the PR number.
*
* Index 0 contains the GitHubRepository dbID and index 1
* contains the PR number.
*/
export type PullRequestKey = [number, number]
export class PullRequestDatabase extends BaseDatabase {
public pullRequests!: Dexie.Table<IPullRequest, number>
public pullRequests!: Dexie.Table<IPullRequest, PullRequestKey>
public pullRequestsLastUpdated!: Dexie.Table<IPullRequestsLastUpdated, number>
public constructor(name: string, schemaVersion?: number) {
super(name, schemaVersion)
@ -95,5 +96,165 @@ export class PullRequestDatabase extends BaseDatabase {
// Remove the pullRequestStatus table
this.conditionalVersion(5, { pullRequestStatus: null })
// Delete pullRequestsTable in order to recreate it again
// in version 7 with a new primary key
this.conditionalVersion(6, { pullRequests: null })
// new primary key and a new table dedicated to keeping track
// of the most recently updated PR we've seen.
this.conditionalVersion(7, {
pullRequests: '[base.repoId+number]',
pullRequestsLastUpdated: 'repoId',
})
}
/**
* Removes all the pull requests associated with the given repository
* from the database. Also clears the last updated date for that repository
* 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,
this.pullRequestsLastUpdated,
async () => {
await this.clearLastUpdated(repository)
await this.pullRequests
.where('[base.repoId+number]')
.between([dbId], [dbId + 1])
.delete()
}
)
}
/**
* Removes all the given pull requests from the database.
*/
public async deletePullRequests(keys: PullRequestKey[]) {
// I believe this to be a bug in Dexie's type declarations.
// It definitely supports passing an array of keys but the
// type thinks that if it's an array it should be an array
// of void which I believe to be a mistake. Therefore we
// type it as any and hand it off to Dexie.
await this.pullRequests.bulkDelete(keys as any)
}
/**
* Inserts the given pull requests, overwriting any existing records
* in the process.
*/
public async putPullRequests(prs: IPullRequest[]) {
await this.pullRequests.bulkPut(prs)
}
/**
* Retrieve all PRs for the given repository.
*
* Note: This method will throw if the GitHubRepository hasn't
* 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])
.toArray()
}
/**
* 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])
}
/**
* Gets a value indicating the most recently updated PR
* that we've seen for a particular repository.
*
* Note:
* This value might differ from max(updated_at) in the pullRequests
* table since the most recently updated PR we saw might have
* been closed and we only store open PRs in the pullRequests
* 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
}
/**
* Clears the stored date for the most recently updated PR seen for
* 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)
}
/**
* Set a value indicating the most recently updated PR
* that we've seen for a particular repository.
*
* Note:
* This value might differ from max(updated_at) in the pullRequests
* table since the most recently updated PR we saw might have
* been closed and we only store open PRs in the pullRequests
* 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(),
})
}
}
/**
* Create a pull request key from a GitHub repository and a PR number.
*
* This method is mainly a helper function to ensure we don't
* accidentally swap the order of the repository id and the pr number
* if we were to create the key array manually.
*
* @param repository The GitHub repository to which this PR belongs
* @param prNumber The PR number as returned from the GitHub API
*/
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
}

View file

@ -23,13 +23,33 @@ export interface IDatabaseGitHubRepository {
readonly lastPruneDate: number | null
}
/** A record to track the protected branch information for a GitHub repository */
export interface IDatabaseProtectedBranch {
readonly repoId: number
/**
* The branch name associated with the branch protection settings
*
* NOTE: this is NOT a fully-qualified ref (i.e. `refs/heads/master`)
*/
readonly name: string
}
export interface IDatabaseRepository {
readonly id?: number | null
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
}
/**
* Branches are keyed on the ID of the GitHubRepository that they belong to
* and the short name of the branch.
*/
type BranchKey = [number, string]
/** The repositories database. */
export class RepositoriesDatabase extends BaseDatabase {
/** The local repositories table. */
@ -38,6 +58,9 @@ export class RepositoriesDatabase extends BaseDatabase {
/** The GitHub repositories table. */
public gitHubRepositories!: Dexie.Table<IDatabaseGitHubRepository, number>
/** A table containing the names of protected branches per repository. */
public protectedBranches!: Dexie.Table<IDatabaseProtectedBranch, BranchKey>
/** The GitHub repository owners table. */
public owners!: Dexie.Table<IDatabaseOwner, number>
@ -74,6 +97,10 @@ export class RepositoriesDatabase extends BaseDatabase {
this.conditionalVersion(5, {
gitHubRepositories: '++id, name, &[ownerID+name], cloneURL',
})
this.conditionalVersion(6, {
protectedBranches: '[repoId+name], repoId',
})
}
}

View file

@ -14,6 +14,8 @@ import { assertNever } from '../fatal-error'
export enum ExternalEditor {
Atom = 'Atom',
AtomBeta = 'Atom Beta',
AtomNightly = 'Atom Nightly',
VisualStudioCode = 'Visual Studio Code',
VisualStudioCodeInsiders = 'Visual Studio Code (Insiders)',
SublimeText = 'Sublime Text',
@ -27,6 +29,12 @@ export function parse(label: string): ExternalEditor | null {
if (label === ExternalEditor.Atom) {
return ExternalEditor.Atom
}
if (label === ExternalEditor.AtomBeta) {
return ExternalEditor.AtomBeta
}
if (label === ExternalEditor.AtomNightly) {
return ExternalEditor.AtomNightly
}
if (label === ExternalEditor.VisualStudioCode) {
return ExternalEditor.VisualStudioCode
}
@ -69,6 +77,22 @@ function getRegistryKeys(
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\atom',
},
]
case ExternalEditor.AtomBeta:
return [
{
key: HKEY.HKEY_CURRENT_USER,
subKey:
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\atom-beta',
},
]
case ExternalEditor.AtomNightly:
return [
{
key: HKEY.HKEY_CURRENT_USER,
subKey:
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\atom-nightly',
},
]
case ExternalEditor.VisualStudioCode:
return [
// 64-bit version of VSCode (user) - provided by default in 64-bit Windows
@ -258,6 +282,10 @@ function getExecutableShim(
switch (editor) {
case ExternalEditor.Atom:
return Path.join(installLocation, 'bin', 'atom.cmd') // remember, CMD must 'useShell'
case ExternalEditor.AtomBeta:
return Path.join(installLocation, 'bin', 'atom-beta.cmd') // remember, CMD must 'useShell'
case ExternalEditor.AtomNightly:
return Path.join(installLocation, 'bin', 'atom-nightly.cmd') // remember, CMD must 'useShell'
case ExternalEditor.VisualStudioCode:
return Path.join(installLocation, 'bin', 'code.cmd') // remember, CMD must 'useShell'
case ExternalEditor.VisualStudioCodeInsiders:
@ -292,6 +320,10 @@ function isExpectedInstallation(
switch (editor) {
case ExternalEditor.Atom:
return displayName === 'Atom' && publisher === 'GitHub Inc.'
case ExternalEditor.AtomBeta:
return displayName === 'Atom Beta' && publisher === 'GitHub Inc.'
case ExternalEditor.AtomNightly:
return displayName === 'Atom Nightly' && publisher === 'GitHub Inc.'
case ExternalEditor.VisualStudioCode:
return (
displayName.startsWith('Microsoft Visual Studio Code') &&
@ -352,6 +384,20 @@ function extractApplicationInformation(
return { displayName, publisher, installLocation }
}
if (editor === ExternalEditor.AtomBeta) {
const displayName = getKeyOrEmpty(keys, 'DisplayName')
const publisher = getKeyOrEmpty(keys, 'Publisher')
const installLocation = getKeyOrEmpty(keys, 'InstallLocation')
return { displayName, publisher, installLocation }
}
if (editor === ExternalEditor.AtomNightly) {
const displayName = getKeyOrEmpty(keys, 'DisplayName')
const publisher = getKeyOrEmpty(keys, 'Publisher')
const installLocation = getKeyOrEmpty(keys, 'InstallLocation')
return { displayName, publisher, installLocation }
}
if (
editor === ExternalEditor.VisualStudioCode ||
editor === ExternalEditor.VisualStudioCodeInsiders
@ -496,6 +542,8 @@ export async function getAvailableEditors(): Promise<
const [
atomPath,
atomBetaPath,
atomNightlyPath,
codePath,
codeInsidersPath,
sublimePath,
@ -504,6 +552,8 @@ export async function getAvailableEditors(): Promise<
slickeditPath,
] = await Promise.all([
findApplication(ExternalEditor.Atom),
findApplication(ExternalEditor.AtomBeta),
findApplication(ExternalEditor.AtomNightly),
findApplication(ExternalEditor.VisualStudioCode),
findApplication(ExternalEditor.VisualStudioCodeInsiders),
findApplication(ExternalEditor.SublimeText),
@ -520,6 +570,22 @@ export async function getAvailableEditors(): Promise<
})
}
if (atomBetaPath) {
results.push({
editor: ExternalEditor.AtomBeta,
path: atomBetaPath,
usesShell: true,
})
}
if (atomNightlyPath) {
results.push({
editor: ExternalEditor.AtomNightly,
path: atomNightlyPath,
usesShell: true,
})
}
if (codePath) {
results.push({
editor: ExternalEditor.VisualStudioCode,

View file

@ -40,7 +40,7 @@ export function lookupPreferredEmail(
*/
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 visiblity.
// older Enterprise Server which doesn't have the concept of visiblity.
return email.visibility === 'public' || !email.visibility
}

View file

@ -1,5 +1,5 @@
/**
* The oldest officially supported version of GitHub Enterprise.
* The oldest officially supported version of GitHub Enterprise Server.
* 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

View file

@ -37,18 +37,13 @@ export function enableRecurseSubmodulesFlag(): boolean {
return enableBetaFeatures()
}
/** Should the app set protocol.version=2 for any fetch/push/pull/clone operation? */
export function enableGitProtocolVersionTwo(): boolean {
return true
}
export function enableReadmeOverwriteWarning(): boolean {
return enableBetaFeatures()
}
/** Shoult the app automatically prune branches that are no longer actively being used */
/** Should the app automatically prune branches that are no longer actively being used */
export function enableBranchPruning(): boolean {
return enableBetaFeatures()
return true
}
/**
@ -60,11 +55,6 @@ export function enableBranchPruning(): boolean {
* just yet.
*/
export function enableNoChangesCreatePRBlankslateAction(): boolean {
return enableBetaFeatures()
}
/** Should the app detect and handle rebase conflicts when `pull.rebase` is set? */
export function enablePullWithRebase(): boolean {
return true
}
@ -73,10 +63,41 @@ export function enablePullWithRebase(): boolean {
* grouping and filtering (GitHub) repositories by owner/organization.
*/
export function enableGroupRepositoriesByOwner(): boolean {
return enableBetaFeatures()
return true
}
/** Should the app show the "rebase current branch" dialog? */
export function enableRebaseDialog(): boolean {
return enableDevelopmentFeatures()
return true
}
/** Should the app show the "stash changes" dialog? */
export function enableStashing(): boolean {
return true
}
/**
* Should the application query for branch protection information and store this
* to help the maintainers understand how broadly branch protections are
* encountered?
*/
export function enableBranchProtectionChecks(): boolean {
return true
}
/** Should the app detect Windows Subsystem for Linux as a valid shell? */
export function enableWSLDetection(): boolean {
return enableBetaFeatures()
}
/**
* Should the application warn the user when they are about to commit to a
* protected branch, and encourage them into a flow to move their changes to
* a new branch?
*
* As this builds upon existing branch protection features in the codebase, this
* flag is linked to to `enableBranchProtectionChecks()`.
*/
export function enableBranchProtectionWarningFlow(): boolean {
return enableBranchProtectionChecks() && enableDevelopmentFeatures()
}

View file

@ -33,7 +33,7 @@ async function canAccessRepositoryUsingAPI(
* @param urlOrRepositoryAlias - the URL or repository alias whose account
* should be found
* @param accounts - the list of active GitHub and GitHub Enterprise
* accounts
* Server accounts
*/
export async function findAccountForRemoteURL(
urlOrRepositoryAlias: string,
@ -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 accounts
// - GitHub Enterprise Server accounts
// - unauthenticated GitHub account (access public repositories)
//
// As this needs to be done efficiently, we consider endpoints not matching
// `getDotComAPIEndpoint()` to be GitHub Enterprise accounts, and accounts
// `getDotComAPIEndpoint()` to be GitHub Enterprise Server accounts, and accounts
// without a token to be unauthenticated.
const sortedAccounts = Array.from(allAccounts).sort((a1, a2) => {
if (a1.endpoint === getDotComAPIEndpoint()) {

View file

@ -3,9 +3,20 @@ type MergeOrPullConflictsErrorContext = {
readonly kind: 'merge' | 'pull'
/** The branch being merged into the current branch, "theirs" in Git terminology */
readonly theirBranch: string
/** The branch associated with the current tip of the repository, "ours" in Git terminology */
readonly currentBranch: string
}
type CheckoutBranchErrorContext = {
/** The Git operation that triggered the error */
readonly kind: 'checkout'
/** The branch associated with the current tip of the repository, "ours" in Git terminology */
readonly branchToCheckout: string
}
/** A custom shape of data for actions to provide to help with error handling */
export type GitErrorContext = MergeOrPullConflictsErrorContext
export type GitErrorContext =
| MergeOrPullConflictsErrorContext
| CheckoutBranchErrorContext

View file

@ -6,18 +6,6 @@ import { IGitAccount } from '../../models/git-account'
import { envForAuthentication } from './authentication'
import { formatAsLocalRef } from './refs'
export interface IMergedBranch {
/**
* The canonical reference to the merged branch
*/
readonly canonicalRef: string
/**
* The full-length Object ID (SHA) in HEX (32 chars)
*/
readonly sha: string
}
/**
* Create a new branch from the given start point.
*
@ -30,9 +18,10 @@ export interface IMergedBranch {
export async function createBranch(
repository: Repository,
name: string,
startPoint?: string
startPoint: string | null
): Promise<Branch | null> {
const args = startPoint ? ['branch', name, startPoint] : ['branch', name]
const args =
startPoint !== null ? ['branch', name, startPoint] : ['branch', name]
try {
await git(args, repository.path, 'createBranch')
@ -183,11 +172,12 @@ export async function getBranchesPointedAt(
*
* @param repository The repository in which to search
* @param branchName The to be used as the base branch
* @returns map of branch canonical refs paired to its sha
*/
export async function getMergedBranches(
repository: Repository,
branchName: string
): Promise<ReadonlyArray<IMergedBranch>> {
): Promise<Map<string, string>> {
const canonicalBranchRef = formatAsLocalRef(branchName)
const args = [
@ -202,7 +192,7 @@ export async function getMergedBranches(
// Remove the trailing newline
lines.splice(-1, 1)
const mergedBranches = new Array<IMergedBranch>()
const mergedBranches = new Map<string, string>()
for (const line of lines) {
const [sha, canonicalRef] = line.split('\0')
@ -217,7 +207,7 @@ export async function getMergedBranches(
continue
}
mergedBranches.push({ sha, canonicalRef })
mergedBranches.set(canonicalRef, sha)
}
return mergedBranches

View file

@ -61,7 +61,7 @@ export async function checkoutBranch(
account: IGitAccount | null,
branch: Branch,
progressCallback?: ProgressCallback
): Promise<void> {
): Promise<true> {
let opts: IGitExecutionOptions = {
env: envForAuthentication(account),
expectedErrors: AuthenticationErrors,
@ -97,6 +97,9 @@ export async function checkoutBranch(
)
await git(args, repository.path, 'checkoutBranch', opts)
// we return `true` here so `GitStore.performFailableGitOperation`
// will return _something_ differentiable from `undefined` if this succeeds
return true
}
/** Check out the paths at HEAD. */

View file

@ -7,7 +7,6 @@ import {
import { assertNever } from '../fatal-error'
import { getDotComAPIEndpoint } from '../api'
import { enableGitProtocolVersionTwo } from '../feature-flag'
import { IGitAccount } from '../../models/git-account'
@ -149,16 +148,18 @@ export async function git(
}
// The caller should either handle this error, or expect that exit code.
const errorMessage = []
const errorMessage = new Array<string>()
errorMessage.push(
`\`git ${args.join(' ')}\` exited with an unexpected code: ${exitCode}.`
)
if (result.stdout) {
errorMessage.push('stdout:')
errorMessage.push(result.stdout)
}
if (result.stderr) {
errorMessage.push('stderr:')
errorMessage.push(result.stderr)
}
@ -180,9 +181,16 @@ function getDescriptionForError(error: DugiteError): string {
case DugiteError.SSHAuthenticationFailed:
case DugiteError.SSHPermissionDenied:
case DugiteError.HTTPSAuthenticationFailed:
return `Authentication failed. You may not have permission to access the repository or the repository may have been archived. Open ${
__DARWIN__ ? 'preferences' : 'options'
} and verify that you're signed in with an account that has permission to access this repository.`
const menuHint = __DARWIN__
? 'GitHub Desktop > Preferences.'
: 'File > Options.'
return `Authentication failed. Some common reasons include:
- You are not logged in to your account: see ${menuHint}
- You may need to log out and log back in to refresh your token.
- You do not have permission to access this repository.
- The repository is archived on GitHub. Check the repository settings to confirm you are still permitted to push commits.
- If you use SSH authentication, check that your key is added to the ssh-agent and associated with your account.`
case DugiteError.RemoteDisconnection:
return 'The remote disconnected. Check your Internet connection and try again.'
case DugiteError.HostDown:
@ -267,7 +275,7 @@ function getDescriptionForError(error: DugiteError): string {
case DugiteError.NoExistingRemoteBranch:
return 'The remote branch does not exist.'
case DugiteError.LocalChangesOverwritten:
return 'Some of your changes would be overwritten.'
return 'Unable to switch branches as there are working directory changes which would be overwritten. Please commit or stash your changes.'
case DugiteError.UnresolvedConflicts:
return 'There are unresolved conflicts in the working directory.'
default:
@ -306,10 +314,6 @@ export async function gitNetworkArguments(
'credential.helper=',
]
if (!enableGitProtocolVersionTwo()) {
return baseArgs
}
if (account === null) {
return baseArgs
}

View file

@ -179,11 +179,23 @@ export async function getChangedFiles(
]
const result = await git(args, repository.path, 'getChangedFiles')
const out = result.stdout
const lines = out.split('\0')
return parseChangedFiles(result.stdout, sha)
}
/**
* Parses git `log` or `diff` output into a list of changed files
* (see `getChangedFiles` for an example of use)
*
* @param stdout raw ouput from a git `-z` and `--name-status` flags
* @param committish commitish command was run against
*/
export function parseChangedFiles(
stdout: string,
committish: string
): ReadonlyArray<CommittedFileChange> {
const lines = stdout.split('\0')
// Remove the trailing empty line
lines.splice(-1, 1)
const files: CommittedFileChange[] = []
for (let i = 0; i < lines.length; i++) {
const statusText = lines[i]
@ -201,7 +213,7 @@ export async function getChangedFiles(
const path = lines[++i]
files.push(new CommittedFileChange(path, status, sha))
files.push(new CommittedFileChange(path, status, committish))
}
return files

View file

@ -9,10 +9,7 @@ import { IPullProgress } from '../../models/progress'
import { IGitAccount } from '../../models/git-account'
import { PullProgressParser, executionOptionsWithProgress } from '../progress'
import { envForAuthentication, AuthenticationErrors } from './authentication'
import {
enableRecurseSubmodulesFlag,
enablePullWithRebase,
} from '../feature-flag'
import { enableRecurseSubmodulesFlag } from '../feature-flag'
async function getPullArgs(
repository: Repository,
@ -24,10 +21,6 @@ async function getPullArgs(
const args = [...networkArguments, 'pull']
if (!enablePullWithRebase()) {
args.push('--no-rebase')
}
if (enableRecurseSubmodulesFlag()) {
args.push('--recurse-submodules')
}

View file

@ -6,9 +6,9 @@ import * as byline from 'byline'
import { Repository } from '../../models/repository'
import {
RebaseContext,
RebaseInternalState,
RebaseProgressOptions,
RebaseProgressSummary,
GitRebaseProgress,
} from '../../models/rebase'
import { IRebaseProgress } from '../../models/progress'
import {
@ -16,6 +16,7 @@ import {
AppFileStatusKind,
} from '../../models/status'
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
import { CommitOneLine } from '../../models/commit'
import { merge } from '../merge'
import { formatRebaseValue } from '../rebase'
@ -25,6 +26,38 @@ import { stageManualConflictResolution } from './stage'
import { stageFiles } from './update-index'
import { getStatus } from './status'
import { getCommitsInRange } from './rev-list'
import { Branch } from '../../models/branch'
/** The app-specific results from attempting to rebase a repository */
export enum RebaseResult {
/**
* Git completed the rebase without reporting any errors, and the caller can
* signal success to the user.
*/
CompletedWithoutError = 'CompletedWithoutError',
/**
* The rebase encountered conflicts while attempting to rebase, and these
* need to be resolved by the user before the rebase can continue.
*/
ConflictsEncountered = 'ConflictsEncountered',
/**
* The rebase was not able to continue as tracked files were not staged in
* the index.
*/
OutstandingFilesNotStaged = 'OutstandingFilesNotStaged',
/**
* The rebase was not attempted because it could not check the status of the
* repository. The caller needs to confirm the repository is in a usable
* state.
*/
Aborted = 'Aborted',
/**
* An unexpected error as part of the rebase flow was caught and handled.
*
* Check the logs to find the relevant Git details.
*/
Error = 'Error',
}
/**
* Check the `.git/REBASE_HEAD` file exists in a repository to confirm
@ -36,16 +69,16 @@ function isRebaseHeadSet(repository: Repository) {
}
/**
* Detect and build up the context about the rebase being performed on a
* repository. This information is required to help Desktop display information
* to the user about the current action as well as the options available.
* Get the internal state about the rebase being performed on a repository. This
* information is required to help Desktop display information to the user
* about the current action as well as the options available.
*
* Returns `null` if no rebase is detected, or if the expected information
* cannot be found in the repository.
*/
export async function getRebaseContext(
export async function getRebaseInternalState(
repository: Repository
): Promise<RebaseContext | null> {
): Promise<RebaseInternalState | null> {
const isRebase = await isRebaseHeadSet(repository)
if (!isRebase) {
@ -95,18 +128,22 @@ export async function getRebaseContext(
}
/**
* Inspect the `.git/rebase-apply` folder and convert the current context into
* a progress summary that can be passed into the rebase flow to hydrate the
* component state.
* Inspect the `.git/rebase-apply` folder and convert the current rebase state
* into data that can be provided to the rebase flow to update the application
* state.
*
* This is required when Desktop is not responsible for initiating the rebase:
*
* - when a rebase outside Desktop encounters conflicts
* - when a `git pull --rebase` was run and encounters conflicts
*
*/
export async function getCurrentProgress(
export async function getRebaseSnapshot(
repository: Repository
): Promise<RebaseProgressSummary | null> {
): Promise<{
progress: GitRebaseProgress
commits: ReadonlyArray<CommitOneLine>
} | null> {
const rebaseHead = await isRebaseHeadSet(repository)
if (!rebaseHead) {
return null
@ -182,13 +219,29 @@ export async function getCurrentProgress(
originalBranchTip
)
if (commits.length === 0) {
if (commits === null || commits.length === 0) {
return null
}
// this number starts from 1, but our array of commits starts from 0
const nextCommitIndex = next - 1
const hasValidCommit =
commits.length > 0 &&
nextCommitIndex >= 0 &&
nextCommitIndex <= commits.length
const currentCommitSummary = hasValidCommit
? commits[nextCommitIndex].summary
: null
return {
rebasedCommitCount: next,
value,
progress: {
value,
rebasedCommitCount: next,
totalCommitCount: last,
currentCommitSummary,
},
commits,
}
}
@ -234,12 +287,17 @@ class GitRebaseParser {
return null
}
const commitSummary = match[1]
const currentCommitSummary = match[1]
this.rebasedCommitCount++
const progress = this.rebasedCommitCount / this.totalCommitCount
const value = formatRebaseValue(progress)
// TODO: dig into why we sometimes get an extra progress event reported
if (this.rebasedCommitCount > this.totalCommitCount) {
this.rebasedCommitCount = this.totalCommitCount
}
return {
kind: 'rebase',
title: `Rebasing commit ${this.rebasedCommitCount} of ${
@ -248,7 +306,7 @@ class GitRebaseParser {
value,
rebasedCommitCount: this.rebasedCommitCount,
totalCommitCount: this.totalCommitCount,
commitSummary,
currentCommitSummary,
}
}
}
@ -293,19 +351,38 @@ function configureOptionsForRebase(
*/
export async function rebase(
repository: Repository,
baseBranch: string,
targetBranch: string,
progress?: RebaseProgressOptions
baseBranch: Branch,
targetBranch: Branch,
progressCallback?: (progress: IRebaseProgress) => void
): Promise<RebaseResult> {
const options = configureOptionsForRebase(
{
expectedErrors: new Set([GitError.RebaseConflicts]),
},
progress
)
const baseOptions: IGitExecutionOptions = {
expectedErrors: new Set([GitError.RebaseConflicts]),
}
let options = baseOptions
if (progressCallback !== undefined) {
const commits = await getCommitsInRange(
repository,
baseBranch.tip.sha,
targetBranch.tip.sha
)
if (commits === null) {
return RebaseResult.Error
}
const totalCommitCount = commits.length
options = configureOptionsForRebase(baseOptions, {
rebasedCommitCount: 0,
totalCommitCount,
progressCallback,
})
}
const result = await git(
['rebase', baseBranch, targetBranch],
['rebase', baseBranch.name, targetBranch.name],
repository.path,
'rebase',
options
@ -319,37 +396,6 @@ export async function abortRebase(repository: Repository) {
await git(['rebase', '--abort'], repository.path, 'abortRebase')
}
/** The app-specific results from attempting to rebase a repository */
export enum RebaseResult {
/**
* Git completed the rebase without reporting any errors, and the caller can
* signal success to the user.
*/
CompletedWithoutError = 'CompletedWithoutError',
/**
* The rebase encountered conflicts while attempting to rebase, and these
* need to be resolved by the user before the rebase can continue.
*/
ConflictsEncountered = 'ConflictsEncountered',
/**
* The rebase was not able to continue as tracked files were not staged in
* the index.
*/
OutstandingFilesNotStaged = 'OutstandingFilesNotStaged',
/**
* The rebase was not attempted because it could not check the status of the
* repository. The caller needs to confirm the repository is in a usable
* state.
*/
Aborted = 'Aborted',
/**
* An unexpected error as part of the rebase flow was caught and handled.
*
* Check the logs to find the relevant Git details.
*/
Error = 'Error',
}
function parseRebaseResult(result: IGitResult): RebaseResult {
if (result.exitCode === 0) {
return RebaseResult.CompletedWithoutError
@ -378,7 +424,7 @@ export async function continueRebase(
repository: Repository,
files: ReadonlyArray<WorkingDirectoryFileChange>,
manualResolutions: ReadonlyMap<string, ManualConflictResolution> = new Map(),
progress?: RebaseProgressOptions
progressCallback?: (progress: IRebaseProgress) => void
): Promise<RebaseResult> {
const trackedFiles = files.filter(f => {
return f.status.kind !== AppFileStatusKind.Untracked
@ -391,7 +437,7 @@ export async function continueRebase(
await stageManualConflictResolution(repository, file, resolution)
} else {
log.error(
`couldn't find file ${path} even though there's a manual resolution for it`
`[continueRebase] couldn't find file ${path} even though there's a manual resolution for it`
)
}
}
@ -401,10 +447,9 @@ export async function continueRebase(
await stageFiles(repository, otherFiles)
const status = await getStatus(repository)
if (status == null) {
log.warn(
`[rebase] unable to get status after staging changes, skipping any other steps`
`[continueRebase] unable to get status after staging changes, skipping any other steps`
)
return RebaseResult.Aborted
}
@ -418,15 +463,34 @@ export async function continueRebase(
f => f.status.kind !== AppFileStatusKind.Untracked
)
const options = configureOptionsForRebase(
{
expectedErrors: new Set([
GitError.RebaseConflicts,
GitError.UnresolvedConflicts,
]),
},
progress
)
const baseOptions: IGitExecutionOptions = {
expectedErrors: new Set([
GitError.RebaseConflicts,
GitError.UnresolvedConflicts,
]),
}
let options = baseOptions
if (progressCallback !== undefined) {
const snapshot = await getRebaseSnapshot(repository)
if (snapshot === null) {
log.warn(
`[continueRebase] unable to get rebase status, skipping any other steps`
)
return RebaseResult.Aborted
}
const { progress } = snapshot
const { rebasedCommitCount, totalCommitCount } = progress
options = configureOptionsForRebase(baseOptions, {
rebasedCommitCount,
totalCommitCount,
progressCallback,
})
}
if (trackedFilesAfter.length === 0) {
log.warn(

View file

@ -69,10 +69,10 @@ export async function getRecentBranches(
* Returns a map keyed on branch names
*
* @param repository the repository who's reflog you want to check
* @param afterDate the minimum date a checkout has to occur
* @param afterDate filters checkouts so that only those occuring on or after this date are returned
* @returns map of branch name -> checkout date
*/
export async function getCheckoutsAfterDate(
export async function getBranchCheckouts(
repository: Repository,
afterDate: Date
): Promise<Map<string, Date>> {

View file

@ -99,12 +99,15 @@ export async function getBranchAheadBehind(
*
* This emulates how `git rebase` initially determines what will be applied to
* the repository.
*
* Returns `null` when the rebase is not possible to perform, because of a
* missing commit ID
*/
export async function getCommitsInRange(
repository: Repository,
baseBranchSha: string,
targetBranchSha: string
): Promise<ReadonlyArray<CommitOneLine>> {
): Promise<ReadonlyArray<CommitOneLine> | null> {
const range = revRange(baseBranchSha, targetBranchSha)
const args = [
@ -118,7 +121,21 @@ export async function getCommitsInRange(
'--',
]
const result = await git(args, repository.path, 'getCommitsInRange')
const options = {
expectedErrors: new Set<GitError>([GitError.BadRevision]),
}
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
}
const lines = result.stdout.split('\n')

View file

@ -1,84 +1,275 @@
import { git } from '.'
import { GitError as DugiteError } from 'dugite'
import { Repository } from '../../models/repository'
import {
IStashEntry,
StashedChangesLoadStates,
StashedFileChanges,
} from '../../models/stash-entry'
import { CommittedFileChange } from '../../models/status'
import { git, GitError } from './core'
import { parseChangedFiles } from './log'
export const DesktopStashEntryMarker = '!!GitHub_Desktop'
export interface IStashEntry {
/** The name of the branch at the time the entry was created. */
readonly branchName: string
/** The SHA of the commit object created as a result of stashing. */
readonly stashSha: string
}
/** RegEx for parsing out the stash SHA and message */
const stashEntryRe = /^([0-9a-f]{40})@(.+)$/
/**
* RegEx for determining if a stash entry is created by Desktop
*
* This is done by looking for a magic string with the following
* format: `!!GitHub_Desktop<branch@commit>`
* format: `!!GitHub_Desktop<branch>`
*/
const stashEntryMessageRe = /^!!GitHub_Desktop<(.+)@([0-9|a-z|A-Z]{40})>$/
const desktopStashEntryMessageRe = /!!GitHub_Desktop<(.+)>$/
type StashResult = {
/** The stash entries created by Desktop */
readonly desktopEntries: ReadonlyArray<IStashEntry>
/**
* The total amount of stash entries,
* i.e. stash entries created both by Desktop and outside of Desktop
*/
readonly stashEntryCount: number
}
/**
* Get the list of stash entries created by Desktop in the current repository
* using the default ordering of refs (which is LIFO ordering),
* as well as the total amount of stash entries.
*/
export async function getDesktopStashEntries(
repository: Repository
): Promise<ReadonlyArray<IStashEntry>> {
const prettyFormat = '%H@%gs'
export async function getStashes(repository: Repository): Promise<StashResult> {
const delimiter = '1F'
const delimiterString = String.fromCharCode(parseInt(delimiter, 16))
const format = ['%gd', '%H', '%gs'].join(`%x${delimiter}`)
const result = await git(
['log', '-g', 'refs/stash', `--pretty=${prettyFormat}`],
['log', '-g', '-z', `--pretty=${format}`, 'refs/stash'],
repository.path,
'getStashEntries'
'getStashEntries',
{
successExitCodes: new Set([0, 128]),
}
)
if (result.stderr !== '') {
//don't really care what the error is right now, but will once dugite is updated
throw new Error(result.stderr)
// There's no refs/stashes reflog in the repository or it's not
// even a repository. In either case we don't care
if (result.exitCode === 128) {
return { desktopEntries: [], stashEntryCount: 0 }
}
const out = result.stdout
const lines = out.split('\n')
const desktopStashEntries: Array<IStashEntry> = []
const files: StashedFileChanges = {
kind: StashedChangesLoadStates.NotLoaded,
}
const stashEntries: Array<IStashEntry> = []
for (const line of lines) {
const match = stashEntryRe.exec(line)
const entries = result.stdout.split('\0').filter(s => s !== '')
for (const entry of entries) {
const pieces = entry.split(delimiterString)
if (match == null) {
continue
if (pieces.length === 3) {
const [name, stashSha, message] = pieces
const branchName = extractBranchFromMessage(message)
if (branchName !== null) {
desktopStashEntries.push({
name,
branchName,
stashSha,
files,
})
}
}
}
return {
desktopEntries: desktopStashEntries,
stashEntryCount: entries.length - 1,
}
}
/**
* Returns the last Desktop created stash entry for the given branch
*/
export async function getLastDesktopStashEntryForBranch(
repository: Repository,
branchName: string
) {
const stash = await getStashes(repository)
// Since stash objects are returned in a LIFO manner, the first
// entry found is guaranteed to be the last entry created
return (
stash.desktopEntries.find(stash => stash.branchName === branchName) || null
)
}
/** Creates a stash entry message that idicates the entry was created by Desktop */
export function createDesktopStashMessage(branchName: string) {
return `${DesktopStashEntryMarker}<${branchName}>`
}
/**
* Stash the working directory changes for the current branch
*/
export async function createDesktopStashEntry(
repository: Repository,
branchName: string
): Promise<true> {
const message = createDesktopStashMessage(branchName)
const args = ['stash', 'push', '--include-untracked', '-m', message]
const result = await git(args, repository.path, 'createStashEntry', {
successExitCodes: new Set<number>([0, 1]),
})
if (result.exitCode === 1) {
// search for any line starting with `error:` - /m here to ensure this is
// applied to each line, without needing to split the text
const errorPrefixRe = /^error: /m
const matches = errorPrefixRe.exec(result.stderr)
if (matches !== null && matches.length > 0) {
// rethrow, because these messages should prevent the stash from being created
throw new GitError(result, args)
}
const message = match[2]
const branchName = extractBranchFromMessage(message)
// if no error messages were emitted by Git, we should log but continue because
// a valid stash was created and this should not interfere with the checkout
// if branch name is null, the stash entry isn't using our magic string
if (branchName === null) {
continue
}
log.info(
`[createDesktopStashEntry] a stash was created successfully but exit code ${
result.exitCode
} reported. stderr: ${result.stderr}`
)
}
stashEntries.push({
branchName: branchName,
stashSha: match[1],
return true
}
async function getStashEntryMatchingSha(repository: Repository, sha: string) {
const stash = await getStashes(repository)
return stash.desktopEntries.find(e => e.stashSha === sha) || null
}
/**
* Removes the given stash entry if it exists
*
* @param stashSha the SHA that identifies the stash entry
*/
export async function dropDesktopStashEntry(
repository: Repository,
stashSha: string
) {
const entryToDelete = await getStashEntryMatchingSha(repository, stashSha)
if (entryToDelete !== null) {
const args = ['stash', 'drop', entryToDelete.name]
await git(args, repository.path, 'dropStashEntry')
}
}
/**
* Pops the stash entry identified by matching `stashSha` to its commit hash.
*
* To see the commit hash of stash entry, run
* `git log -g refs/stash --pretty="%nentry: %gd%nsubject: %gs%nhash: %H%n"`
* in a repo with some stash entries.
*/
export async function popStashEntry(
repository: Repository,
stashSha: string
): Promise<void> {
// ignoring these git errors for now, this will change when we start
// implementing the stash conflict flow
const expectedErrors = new Set<DugiteError>([DugiteError.MergeConflicts])
const successExitCodes = new Set<number>([0, 1])
const stashToPop = await getStashEntryMatchingSha(repository, stashSha)
if (stashToPop !== null) {
const args = ['stash', 'pop', '--quiet', `${stashToPop.name}`]
const result = await git(args, repository.path, 'popStashEntry', {
expectedErrors,
successExitCodes,
})
}
return stashEntries
// popping a stashes that create conflicts in the working directory
// report an exit code of `1` and are not dropped after being applied.
// so, we check for this case and drop them manually
if (result.exitCode === 1) {
if (result.stderr.length > 0) {
// rethrow, because anything in stderr should prevent the stash from being popped
throw new GitError(result, args)
}
log.info(
`[popStashEntry] a stash was popped successfully but exit code ${
result.exitCode
} reported.`
)
// bye bye
await dropDesktopStashEntry(repository, stashSha)
}
}
}
function extractBranchFromMessage(message: string): string | null {
const [, desktopMessage] = message.split(':').map(s => s.trim())
const match = stashEntryMessageRe.exec(desktopMessage)
if (match === null) {
return null
const match = desktopStashEntryMessageRe.exec(message)
return match === null || match[1].length === 0 ? null : match[1]
}
/**
* Get the files that were changed in the given stash commit.
*
* This is different than `getChangedFiles` because stashes
* have _3 parents(!!!)_
*/
export async function getStashedFiles(
repository: Repository,
stashSha: string
): Promise<ReadonlyArray<CommittedFileChange>> {
const [trackedFiles, untrackedFiles] = await Promise.all([
getChangedFilesWithinStash(repository, stashSha),
getChangedFilesWithinStash(repository, `${stashSha}^3`),
])
const files = new Map<string, CommittedFileChange>()
trackedFiles.forEach(x => files.set(x.path, x))
untrackedFiles.forEach(x => files.set(x.path, x))
return [...files.values()].sort((x, y) => x.path.localeCompare(y.path))
}
/**
* Same thing as `getChangedFiles` but with extra handling for 128 exit code
* (which happens if the commit's parent is not valid)
*
* **TODO:** merge this with `getChangedFiles` in `log.ts`
*/
async function getChangedFilesWithinStash(repository: Repository, sha: string) {
// opt-in for rename detection (-M) and copies detection (-C)
// this is equivalent to the user configuring 'diff.renames' to 'copies'
// NOTE: order here matters - doing -M before -C means copies aren't detected
const args = [
'log',
sha,
'-C',
'-M',
'-m',
'-1',
'--no-show-signature',
'--first-parent',
'--name-status',
'--format=format:',
'-z',
'--',
]
const result = await git(args, repository.path, 'getChangedFilesForStash', {
// if this fails, its most likely
// because there weren't any untracked files,
// and that's okay!
successExitCodes: new Set([0, 128]),
})
if (result.exitCode === 0 && result.stdout.length > 0) {
return parseChangedFiles(result.stdout, sha)
}
const branchName = match[1]
return branchName.length > 0 ? branchName : null
}
export function createStashMessage(branchName: string, tipSha: string) {
return `${DesktopStashEntryMarker}<${branchName}@${tipSha}>`
return []
}

View file

@ -25,9 +25,8 @@ import { IAheadBehind } from '../../models/branch'
import { fatalError } from '../../lib/fatal-error'
import { isMergeHeadSet } from './merge'
import { getBinaryPaths } from './diff'
import { getRebaseContext } from './rebase'
import { enablePullWithRebase } from '../feature-flag'
import { RebaseContext } from '../../models/rebase'
import { getRebaseInternalState } from './rebase'
import { RebaseInternalState } from '../../models/rebase'
/**
* V8 has a limit on the size of string it can create (~256MB), and unless we want to
@ -60,7 +59,7 @@ export interface IStatusResult {
readonly mergeHeadFound: boolean
/** details about the rebase operation, if found */
readonly rebaseContext: RebaseContext | null
readonly rebaseInternalState: RebaseInternalState | null
/** the absolute path to the repository's working directory */
readonly workingDirectory: WorkingDirectoryStatus
@ -150,6 +149,10 @@ function convertToAppStatus(
return fatalError(`Unknown file status ${status}`)
}
// List of known conflicted index entries for a file, extracted from mapStatus
// inside `app/src/lib/status-parser.ts` for convenience
const conflictStatusCodes = ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']
/**
* Retrieve the status for a given repository,
* and fail gracefully if the location is not a Git repository
@ -196,20 +199,18 @@ export async function getStatus(
const headers = parsed.filter(isStatusHeader)
const entries = parsed.filter(isStatusEntry)
let conflictDetails: ConflictFilesDetails
const mergeHeadFound = await isMergeHeadSet(repository)
const rebaseContext = await getRebaseContext(repository)
const conflictedFilesInIndex = entries.some(
e => conflictStatusCodes.indexOf(e.statusCode) > -1
)
const rebaseInternalState = await getRebaseInternalState(repository)
if (enablePullWithRebase()) {
conflictDetails = await getConflictDetails(
repository,
mergeHeadFound,
rebaseContext
)
} else {
conflictDetails = await getConflictDetails(repository, mergeHeadFound, null)
}
const conflictDetails = await getConflictDetails(
repository,
mergeHeadFound,
conflictedFilesInIndex,
rebaseInternalState
)
// Map of files keyed on their paths.
const files = entries.reduce(
@ -239,7 +240,7 @@ export async function getStatus(
branchAheadBehind,
exists: true,
mergeHeadFound,
rebaseContext,
rebaseInternalState,
workingDirectory,
}
}
@ -357,25 +358,53 @@ async function getRebaseConflictDetails(repository: Repository) {
}
}
/**
* We need to do these operations to detect conflicts that were the result
* of popping a stash into the index
*/
async function getWorkingDirectoryConflictDetails(repository: Repository) {
const conflictCountsByPath = await getFilesWithConflictMarkers(
repository.path
)
let binaryFilePaths: ReadonlyArray<string> = []
try {
// its totally fine if HEAD doesn't exist, which throws an error
binaryFilePaths = await getBinaryPaths(repository, 'HEAD')
} catch (error) {}
return {
conflictCountsByPath,
binaryFilePaths,
}
}
/**
* gets the conflicted files count and binary file paths in a given repository.
* for computing an `IStatusResult`.
*
* @param repository to get details from
* @param mergeHeadFound whether a merge conflict has been detected
* @param rebaseContext details about the current rebase operation (if found)
* @param lookForStashConflicts whether it looks like a stash has introduced conflicts
* @param rebaseInternalState details about the current rebase operation (if found)
*/
async function getConflictDetails(
repository: Repository,
mergeHeadFound: boolean,
rebaseContext: RebaseContext | null
lookForStashConflicts: boolean,
rebaseInternalState: RebaseInternalState | null
): Promise<ConflictFilesDetails> {
try {
if (mergeHeadFound) {
return await getMergeConflictDetails(repository)
} else if (rebaseContext !== null) {
}
if (rebaseInternalState !== null) {
return await getRebaseConflictDetails(repository)
}
if (lookForStashConflicts) {
return await getWorkingDirectoryConflictDetails(repository)
}
} catch (error) {
log.error(
'Unexpected error from git operations in getConflictDetails',

View file

@ -188,13 +188,6 @@ interface XMLHttpRequest extends XMLHttpRequestEventTarget {
}
declare namespace Electron {
interface MenuItem {
readonly accelerator?: Electron.Accelerator
readonly submenu?: Electron.Menu
readonly role?: string
readonly type: 'normal' | 'separator' | 'submenu' | 'checkbox' | 'radio'
}
interface RequestOptions {
readonly method: string
readonly url: string

View file

@ -23,7 +23,7 @@ export function generateGravatarUrl(email: string, size: number = 60): string {
* endpoint associated with an account.
*
* This is a workaround for a current limitation with
* GitHub Enterprise, where avatar URLs are inaccessible
* GitHub Enterprise Server, where avatar URLs are inaccessible
* in some scenarios.
*
* @param avatar_url The canonical avatar to use

View file

@ -1,4 +1,5 @@
import * as appProxy from '../ui/lib/app-proxy'
import { URL } from 'url'
/** The HTTP methods available. */
export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'HEAD'
@ -87,7 +88,16 @@ export function getAbsoluteUrl(endpoint: string, path: string): string {
if (relativePath.startsWith('api/v3/')) {
relativePath = relativePath.substr(7)
}
return encodeURI(`${endpoint}/${relativePath}`)
// 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
// 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.
const base = endpoint.endsWith('/') ? endpoint : `${endpoint}/`
return new URL(relativePath, base).toString()
}
/**

View file

@ -17,7 +17,7 @@ export interface IMenuItem {
* When specified the click property will be ignored.
* See https://electronjs.org/docs/api/menu-item#roles
*/
readonly role?: string
readonly role?: Electron.MenuItemConstructorOptions['role']
}
/**

View file

@ -1,4 +1,4 @@
import { MenuIDs } from '../main-process/menu'
import { MenuIDs } from '../models/menu-ids'
import { merge } from './merge'
import { IAppState, SelectionType } from '../lib/app-state'
import { Repository } from '../models/repository'
@ -100,6 +100,7 @@ function menuItemStateEqual(state: IMenuItemState, menuItem: MenuItem) {
const allMenuIds: ReadonlyArray<MenuIDs> = [
'rename-branch',
'delete-branch',
'discard-all-changes',
'preferences',
'update-branch',
'compare-to-branch',
@ -149,17 +150,19 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
let onNonDefaultBranch = false
let onBranch = false
let onDetachedHead = false
let hasChangedFiles = false
let hasDefaultBranch = false
let hasPublishedBranch = false
let networkActionInProgress = false
let tipStateIsUnknown = false
let branchIsUnborn = false
let rebaseInProgress = false
let branchHasStashEntry = false
if (selectedState && selectedState.type === SelectionType.Repository) {
repositorySelected = true
const branchesState = selectedState.state.branchesState
const { branchesState, changesState } = selectedState.state
const tip = branchesState.tip
const defaultBranch = branchesState.defaultBranch
@ -181,15 +184,17 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
}
hasPublishedBranch = !!tip.branch.upstream
branchHasStashEntry = changesState.stashEntry !== null
} else {
onNonDefaultBranch = true
}
networkActionInProgress = selectedState.state.isPushPullFetchInProgress
const { conflictState } = selectedState.state.changesState
const { conflictState, workingDirectory } = selectedState.state.changesState
rebaseInProgress = conflictState !== null && conflictState.kind === 'rebase'
hasChangedFiles = workingDirectory.files.length > 0
}
// These are IDs for menu items that are entirely _and only_
@ -258,7 +263,13 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
!tipStateIsUnknown && !branchIsUnborn && !rebaseInProgress
)
menuStateBuilder.setEnabled(
'discard-all-changes',
repositoryActive && hasChangedFiles && !rebaseInProgress
)
menuStateBuilder.setEnabled('compare-to-branch', !onDetachedHead)
menuStateBuilder.setEnabled('toggle-stashed-changes', branchHasStashEntry)
if (
selectedState &&
@ -287,6 +298,7 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
menuStateBuilder.disable('create-branch')
menuStateBuilder.disable('rename-branch')
menuStateBuilder.disable('delete-branch')
menuStateBuilder.disable('discard-all-changes')
menuStateBuilder.disable('update-branch')
menuStateBuilder.disable('merge-branch')
menuStateBuilder.disable('rebase-branch')
@ -295,7 +307,9 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
menuStateBuilder.disable('pull')
menuStateBuilder.disable('compare-to-branch')
menuStateBuilder.disable('compare-on-github')
menuStateBuilder.disable('toggle-stashed-changes')
}
return menuStateBuilder
}

View file

@ -1,14 +1,22 @@
import { IRepositoryState, RebaseConflictState } from '../lib/app-state'
import {
IRepositoryState,
RebaseConflictState,
IBranchesState,
} from '../lib/app-state'
import {
ChooseBranchesStep,
RebaseStep,
ShowConflictsStep,
} from '../models/rebase-flow-state'
import { Branch } from '../models/branch'
} from '../models/rebase-flow-step'
import { Branch, IAheadBehind } from '../models/branch'
import { TipState } from '../models/tip'
import { clamp } from './clamp'
export const initializeNewRebaseFlow = (state: IRepositoryState) => {
/**
* Setup the rebase flow state when the user neeeds to select a branch as the
* base for the operation.
*/
export function initializeNewRebaseFlow(state: IRepositoryState) {
const {
defaultBranch,
allBranches,
@ -36,17 +44,21 @@ export const initializeNewRebaseFlow = (state: IRepositoryState) => {
return initialState
}
export const initializeRebaseFlowForConflictedRepository = (
/**
* Setup the rebase flow when rebase conflicts are detected in the repository.
*
* This indicates a rebase is in progress, and the application needs to guide
* the user to resolve conflicts and complete the rebae.
*
* @param conflictState current set of conflicts
*/
export function initializeRebaseFlowForConflictedRepository(
conflictState: RebaseConflictState
) => {
const { targetBranch, baseBranch } = conflictState
): ShowConflictsStep {
const initialState: ShowConflictsStep = {
kind: RebaseStep.ShowConflicts,
targetBranch,
baseBranch,
conflictState,
}
return initialState
}
@ -58,3 +70,30 @@ export const initializeRebaseFlowForConflictedRepository = (
export function formatRebaseValue(value: number) {
return Math.round(clamp(value, 0, 1) * 100) / 100
}
/**
* Check application state to see whether the action applied to the current
* branch should be a force push
*/
export function isCurrentBranchForcePush(
branchesState: IBranchesState,
aheadBehind: IAheadBehind | null
) {
if (aheadBehind === null) {
// no tracking branch found
return false
}
const { tip, rebasedBranches } = branchesState
const { ahead, behind } = aheadBehind
let branchWasRebased = false
if (tip.kind === TipState.Valid) {
const localBranchName = tip.branch.nameWithoutRemote
const { sha } = tip.branch.tip
const foundEntry = rebasedBranches.get(localBranchName)
branchWasRebased = foundEntry === sha
}
return branchWasRebased && behind > 0 && ahead > 0
}

View file

@ -57,12 +57,10 @@ async function getRawShellEnv(): Promise<string | null> {
cleanup()
}, 5000)
const options = {
child = ChildProcess.spawn(shell, ['-ilc', 'command env'], {
detached: true,
stdio: ['ignore', 'pipe', process.stderr],
}
child = ChildProcess.spawn(shell, ['-ilc', 'command env'], options)
})
const buffers: Array<Buffer> = []

View file

@ -9,6 +9,7 @@ export enum Shell {
Hyper = 'Hyper',
iTerm2 = 'iTerm2',
PowerShellCore = 'PowerShell Core',
Kitty = 'Kitty',
}
export const Default = Shell.Terminal
@ -30,6 +31,10 @@ export function parse(label: string): Shell {
return Shell.PowerShellCore
}
if (label === Shell.Kitty) {
return Shell.Kitty
}
return Default
}
@ -43,6 +48,8 @@ function getBundleID(shell: Shell): string {
return 'co.zeit.hyper'
case Shell.PowerShellCore:
return 'com.microsoft.powershell'
case Shell.Kitty:
return 'net.kovidgoyal.kitty'
default:
return assertNever(shell, `Unknown shell: ${shell}`)
}
@ -66,11 +73,13 @@ export async function getAvailableShells(): Promise<
hyperPath,
iTermPath,
powerShellCorePath,
kittyPath,
] = await Promise.all([
getShellPath(Shell.Terminal),
getShellPath(Shell.Hyper),
getShellPath(Shell.iTerm2),
getShellPath(Shell.PowerShellCore),
getShellPath(Shell.Kitty),
])
const shells: Array<IFoundShell<Shell>> = []
@ -90,6 +99,11 @@ export async function getAvailableShells(): Promise<
shells.push({ shell: Shell.PowerShellCore, path: powerShellCorePath })
}
if (kittyPath) {
const kittyExecutable = `${kittyPath}/Contents/MacOS/kitty`
shells.push({ shell: Shell.Kitty, path: kittyExecutable })
}
return shells
}
@ -97,7 +111,16 @@ export function launch(
foundShell: IFoundShell<Shell>,
path: string
): ChildProcess {
const bundleID = getBundleID(foundShell.shell)
const commandArgs = ['-b', bundleID, path]
return spawn('open', commandArgs)
if (foundShell.shell === Shell.Kitty) {
// kitty does not handle arguments as expected when using `open` with
// an existing session but closed window (it reverts to the previous
// directory rather than using the new directory directory).
//
// This workaround launches the internal `kitty` executable which
// will open a new window to the desired path.
return spawn(foundShell.path, ['--single-instance', '--directory', path])
} else {
const bundleID = getBundleID(foundShell.shell)
return spawn('open', ['-b', bundleID, path])
}
}

View file

@ -5,6 +5,7 @@ import { pathExists } from 'fs-extra'
import { assertNever } from '../fatal-error'
import { IFoundShell } from './found-shell'
import { enableWSLDetection } from '../feature-flag'
export enum Shell {
Cmd = 'Command Prompt',
@ -13,6 +14,7 @@ export enum Shell {
Hyper = 'Hyper',
GitBash = 'Git Bash',
Cygwin = 'Cygwin',
WSL = 'WSL',
}
export const Default = Shell.Cmd
@ -42,6 +44,10 @@ export function parse(label: string): Shell {
return Shell.Cygwin
}
if (label === Shell.WSL) {
return Shell.WSL
}
return Default
}
@ -95,6 +101,16 @@ export async function getAvailableShells(): Promise<
})
}
if (enableWSLDetection()) {
const wslPath = await findWSL()
if (wslPath != null) {
shells.push({
shell: Shell.WSL,
path: wslPath,
})
}
}
return shells
}
@ -268,6 +284,45 @@ async function findCygwin(): Promise<string | null> {
return null
}
async function findWSL(): Promise<string | null> {
const system32 = Path.join(
process.env.SystemRoot || 'C:\\Windows',
'System32'
)
const wslPath = Path.join(system32, 'wsl.exe')
const wslConfigPath = Path.join(system32, 'wslconfig.exe')
if (!(await pathExists(wslPath))) {
log.debug(`[WSL] wsl.exe does not exist at '${wslPath}'`)
return null
}
if (!(await pathExists(wslConfigPath))) {
log.debug(
`[WSL] found wsl.exe, but wslconfig.exe does not exist at '${wslConfigPath}'`
)
return null
}
const exitCode = new Promise<number>((resolve, reject) => {
const wslDistros = spawn(wslConfigPath, ['/list'])
wslDistros.on('error', reject)
wslDistros.on('exit', resolve)
})
try {
const result = await exitCode
if (result !== 0) {
log.debug(
`[WSL] found wsl.exe and wslconfig.exe, but no distros are installed. Error Code: ${result}`
)
return null
}
return wslPath
} catch (err) {
log.error(`[WSL] unhandled error when invoking 'wsl /list'`, err)
}
return null
}
export function launch(
foundShell: IFoundShell<Shell>,
path: string
@ -312,6 +367,8 @@ export function launch(
cwd: path,
}
)
case Shell.WSL:
return spawn('START', ['wsl'], { shell: true, cwd: path })
case Shell.Cmd:
return spawn('START', ['cmd'], { shell: true, cwd: path })
default:

View file

@ -89,10 +89,10 @@ export interface IDailyMeasures {
/** 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 instance */
/** The number of times the user pushed to a GitHub Enterprise Server instance */
readonly enterprisePushCount: number
/** The number of times the user pushes with `--force-with-lease` to a GitHub Enterprise instance */
/** The number of times the user pushes with `--force-with-lease` to a GitHub Enterprise Server instance */
readonly enterpriseForcePushCount: number
/** The number of times the users pushes to a generic remote */
@ -130,13 +130,19 @@ export interface IDailyMeasures {
/**
* The number of times the user made a commit to a repo hosted on
* a GitHub Enterprise instance
* a GitHub Enterprise Server instance
*/
readonly enterpriseCommits: number
/** The number of time the user made a commit to a repo hosted on Github.com */
/** 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 */
readonly commitsToProtectedBranch: number
/** The number of times the user made a commit to a repository with branch protections enabled */
readonly commitsToRepositoryWithBranchProtections: number
/** The number of times the user dismissed the merge conflicts dialog */
readonly mergeConflictsDialogDismissalCount: number
@ -164,17 +170,104 @@ export interface IDailyMeasures {
/** The number of times an aborted rebase is detected */
readonly rebaseAbortedAfterConflictsCount: number
/** The number of times a successful rebase is detected */
/** The number of times a successful rebase after handling conflicts is detected */
readonly rebaseSuccessAfterConflictsCount: number
/** The number of times a successful rebase without conflicts is detected */
readonly rebaseSuccessWithoutConflictsCount: number
/** The number of times a user performed a pull with `pull.rebase` in config set to `true` */
readonly pullWithRebaseCount: number
/** The number of times a user has pulled with `pull.rebase` unset or set to `false` */
readonly pullWithDefaultSettingCount: number
/**
* The number of stash entries created outside of Desktop
* in a given 24 hour day
*/
readonly stashEntriesCreatedOutsideDesktop: number
/**
* The number of times the user is presented with the error
* message "Some of your changes would be overwritten"
*/
readonly errorWhenSwitchingBranchesWithUncommmittedChanges: number
/** The number of times the user opens the "Rebase current branch" menu item */
readonly rebaseCurrentBranchMenuCount: number
/** The number of times the user views a stash entry after checking out a branch */
readonly stashViewedAfterCheckoutCount: number
/** The number of times the user **doesn't** view a stash entry after checking out a branch */
readonly stashNotViewedAfterCheckoutCount: number
/** The number of times the user elects to stash changes on the current branch */
readonly stashCreatedOnCurrentBranchCount: number
/** The number of times the user elects to take changes to new branch instead of stashing them */
readonly changesTakenToNewBranchCount: number
/** The number of times the user elects to restore an entry from their stash */
readonly stashRestoreCount: number
/** The number of times the user elects to discard a stash entry */
readonly stashDiscardCount: number
/**
* The number of times the user views the stash entry as a result
* of clicking the "Stashed changes" row directly
*/
readonly stashViewCount: number
/** The number of times the user takes no action on a stash entry once viewed */
readonly noActionTakenOnStashCount: number
/**
* The number of times the user has opened their external editor from the
* suggested next steps view
*/
readonly suggestedStepOpenInExternalEditor: number
/**
* The number of times the user has opened their repository in Finder/Explorer
* from the suggested next steps view
*/
readonly suggestedStepOpenWorkingDirectory: number
/**
* The number of times the user has opened their repository on GitHub from the
* suggested next steps view
*/
readonly suggestedStepViewOnGitHub: number
/**
* The number of times the user has used the publish repository action from the
* suggested next steps view
*/
readonly suggestedStepPublishRepository: number
/**
* The number of times the user has used the publish branch action branch from
* the suggested next steps view
*/
readonly suggestedStepPublishBranch: number
/**
* The number of times the user has used the Create PR suggestion
* in the suggested next steps view. Note that this number is a
* subset of `createPullRequestCount`. I.e. if the Create PR suggestion
* is invoked both `suggestedStepCreatePR` and `createPullRequestCount`
* will increment whereas if a PR is created from the menu or from
* a keyboard shortcut only `createPullRequestCount` will increment.
*/
readonly suggestedStepCreatePullRequest: number
/**
* The number of times the user has used the view stash action from
* the suggested next steps view
*/
readonly suggestedStepViewStash: number
}
export class StatsDatabase extends Dexie {

View file

@ -89,9 +89,29 @@ const DefaultDailyMeasures: IDailyMeasures = {
rebaseConflictsDialogReopenedCount: 0,
rebaseAbortedAfterConflictsCount: 0,
rebaseSuccessAfterConflictsCount: 0,
rebaseSuccessWithoutConflictsCount: 0,
pullWithRebaseCount: 0,
pullWithDefaultSettingCount: 0,
stashEntriesCreatedOutsideDesktop: 0,
errorWhenSwitchingBranchesWithUncommmittedChanges: 0,
rebaseCurrentBranchMenuCount: 0,
stashViewedAfterCheckoutCount: 0,
stashCreatedOnCurrentBranchCount: 0,
stashNotViewedAfterCheckoutCount: 0,
changesTakenToNewBranchCount: 0,
stashRestoreCount: 0,
stashDiscardCount: 0,
stashViewCount: 0,
noActionTakenOnStashCount: 0,
suggestedStepOpenInExternalEditor: 0,
suggestedStepOpenWorkingDirectory: 0,
suggestedStepViewOnGitHub: 0,
suggestedStepPublishRepository: 0,
suggestedStepPublishBranch: 0,
suggestedStepCreatePullRequest: 0,
suggestedStepViewStash: 0,
commitsToProtectedBranch: 0,
commitsToRepositoryWithBranchProtections: 0,
}
interface IOnboardingStats {
@ -151,7 +171,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. This metric
* to GitHub.com or GitHub Enterprise Server. This metric
* does not track pushes to non-GitHub remotes.
*/
readonly timeToFirstGitHubPush?: number
@ -233,7 +253,7 @@ interface ICalculatedStats {
/** Is the user logged in with a GitHub.com account? */
readonly dotComAccount: boolean
/** Is the user logged in with an Enterprise account? */
/** Is the user logged in with an Enterprise Server account? */
readonly enterpriseAccount: boolean
/**
@ -648,8 +668,8 @@ 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, meaning that the commit will not be attributed to the user's
* account.
* Enterprise Server, meaning that the commit will not be attributed to the
* user's account.
*/
public recordUnattributedCommit(): Promise<void> {
return this.updateDailyMeasures(m => ({
@ -659,7 +679,7 @@ export class StatsStore implements IStatsStore {
/**
* Records that the user made a commit to a repository hosted on
* a GitHub Enterprise instance
* a GitHub Enterprise Server instance
*/
public recordCommitToEnterprise(): Promise<void> {
return this.updateDailyMeasures(m => ({
@ -674,6 +694,21 @@ export class StatsStore implements IStatsStore {
}))
}
/** Record the user made a commit to a protected GitHub or GitHub Enterprise Server repository */
public recordCommitToProtectedBranch(): Promise<void> {
return this.updateDailyMeasures(m => ({
commitsToProtectedBranch: m.commitsToProtectedBranch + 1,
}))
}
/** Record the user made a commit to repository which has branch protections enabled */
public recordCommitToRepositoryWithBranchProtections(): Promise<void> {
return this.updateDailyMeasures(m => ({
commitsToRepositoryWithBranchProtections:
m.commitsToRepositoryWithBranchProtections + 1,
}))
}
/** Set whether the user has opted out of stats reporting. */
public async setOptOut(
optOut: boolean,
@ -698,14 +733,14 @@ export class StatsStore implements IStatsStore {
}
/** Record that user dismissed diverging branch notification */
public async recordDivergingBranchBannerDismissal(): Promise<void> {
public recordDivergingBranchBannerDismissal(): Promise<void> {
return this.updateDailyMeasures(m => ({
divergingBranchBannerDismissal: m.divergingBranchBannerDismissal + 1,
}))
}
/** Record that user initiated a merge from within the notification banner */
public async recordDivergingBranchBannerInitatedMerge(): Promise<void> {
public recordDivergingBranchBannerInitatedMerge(): Promise<void> {
return this.updateDailyMeasures(m => ({
divergingBranchBannerInitatedMerge:
m.divergingBranchBannerInitatedMerge + 1,
@ -713,7 +748,7 @@ export class StatsStore implements IStatsStore {
}
/** Record that user initiated a compare from within the notification banner */
public async recordDivergingBranchBannerInitiatedCompare(): Promise<void> {
public recordDivergingBranchBannerInitiatedCompare(): Promise<void> {
return this.updateDailyMeasures(m => ({
divergingBranchBannerInitiatedCompare:
m.divergingBranchBannerInitiatedCompare + 1,
@ -724,7 +759,7 @@ export class StatsStore implements IStatsStore {
* Record that user initiated a merge after getting to compare view
* from within notification banner
*/
public async recordDivergingBranchBannerInfluencedMerge(): Promise<void> {
public recordDivergingBranchBannerInfluencedMerge(): Promise<void> {
return this.updateDailyMeasures(m => ({
divergingBranchBannerInfluencedMerge:
m.divergingBranchBannerInfluencedMerge + 1,
@ -732,7 +767,7 @@ export class StatsStore implements IStatsStore {
}
/** Record that the user was shown the notification banner */
public async recordDivergingBranchBannerDisplayed(): Promise<void> {
public recordDivergingBranchBannerDisplayed(): Promise<void> {
return this.updateDailyMeasures(m => ({
divergingBranchBannerDisplayed: m.divergingBranchBannerDisplayed + 1,
}))
@ -766,7 +801,7 @@ export class StatsStore implements IStatsStore {
createLocalStorageTimestamp(FirstPushToGitHubAtKey)
}
/** Record that the user pushed to a GitHub Enterprise instance */
/** Record that the user pushed to a GitHub Enterprise Server instance */
private async recordPushToGitHubEnterprise(
options?: PushOptions
): Promise<void> {
@ -801,21 +836,21 @@ export class StatsStore implements IStatsStore {
}
/** Record that the user saw a 'merge conflicts' warning but continued with the merge */
public async recordUserProceededWhileLoading(): Promise<void> {
public recordUserProceededWhileLoading(): Promise<void> {
return this.updateDailyMeasures(m => ({
mergedWithLoadingHintCount: m.mergedWithLoadingHintCount + 1,
}))
}
/** Record that the user saw a 'merge conflicts' warning but continued with the merge */
public async recordMergeHintSuccessAndUserProceeded(): Promise<void> {
public recordMergeHintSuccessAndUserProceeded(): Promise<void> {
return this.updateDailyMeasures(m => ({
mergedWithCleanMergeHintCount: m.mergedWithCleanMergeHintCount + 1,
}))
}
/** Record that the user saw a 'merge conflicts' warning but continued with the merge */
public async recordUserProceededAfterConflictWarning(): Promise<void> {
public recordUserProceededAfterConflictWarning(): Promise<void> {
return this.updateDailyMeasures(m => ({
mergedWithConflictWarningHintCount:
m.mergedWithConflictWarningHintCount + 1,
@ -825,7 +860,7 @@ export class StatsStore implements IStatsStore {
/**
* Increments the `mergeConflictsDialogDismissalCount` metric
*/
public async recordMergeConflictsDialogDismissal(): Promise<void> {
public recordMergeConflictsDialogDismissal(): Promise<void> {
return this.updateDailyMeasures(m => ({
mergeConflictsDialogDismissalCount:
m.mergeConflictsDialogDismissalCount + 1,
@ -835,7 +870,7 @@ export class StatsStore implements IStatsStore {
/**
* Increments the `anyConflictsLeftOnMergeConflictsDialogDismissalCount` metric
*/
public async recordAnyConflictsLeftOnMergeConflictsDialogDismissal(): Promise<
public recordAnyConflictsLeftOnMergeConflictsDialogDismissal(): Promise<
void
> {
return this.updateDailyMeasures(m => ({
@ -847,7 +882,7 @@ export class StatsStore implements IStatsStore {
/**
* Increments the `mergeConflictsDialogReopenedCount` metric
*/
public async recordMergeConflictsDialogReopened(): Promise<void> {
public recordMergeConflictsDialogReopened(): Promise<void> {
return this.updateDailyMeasures(m => ({
mergeConflictsDialogReopenedCount:
m.mergeConflictsDialogReopenedCount + 1,
@ -857,7 +892,7 @@ export class StatsStore implements IStatsStore {
/**
* Increments the `guidedConflictedMergeCompletionCount` metric
*/
public async recordGuidedConflictedMergeCompletion(): Promise<void> {
public recordGuidedConflictedMergeCompletion(): Promise<void> {
return this.updateDailyMeasures(m => ({
guidedConflictedMergeCompletionCount:
m.guidedConflictedMergeCompletionCount + 1,
@ -867,7 +902,7 @@ export class StatsStore implements IStatsStore {
/**
* Increments the `unguidedConflictedMergeCompletionCount` metric
*/
public async recordUnguidedConflictedMergeCompletion(): Promise<void> {
public recordUnguidedConflictedMergeCompletion(): Promise<void> {
return this.updateDailyMeasures(m => ({
unguidedConflictedMergeCompletionCount:
m.unguidedConflictedMergeCompletionCount + 1,
@ -877,7 +912,7 @@ export class StatsStore implements IStatsStore {
/**
* Increments the `createPullRequestCount` metric
*/
public async recordCreatePullRequest(): Promise<void> {
public recordCreatePullRequest(): Promise<void> {
return this.updateDailyMeasures(m => ({
createPullRequestCount: m.createPullRequestCount + 1,
}))
@ -886,7 +921,7 @@ export class StatsStore implements IStatsStore {
/**
* Increments the `rebaseConflictsDialogDismissalCount` metric
*/
public async recordRebaseConflictsDialogDismissal(): Promise<void> {
public recordRebaseConflictsDialogDismissal(): Promise<void> {
return this.updateDailyMeasures(m => ({
rebaseConflictsDialogDismissalCount:
m.rebaseConflictsDialogDismissalCount + 1,
@ -894,9 +929,9 @@ export class StatsStore implements IStatsStore {
}
/**
* Increments the `rebaseConflictsDialogDismissalCount` metric
* Increments the `rebaseConflictsDialogReopenedCount` metric
*/
public async recordRebaseConflictsDialogReopened(): Promise<void> {
public recordRebaseConflictsDialogReopened(): Promise<void> {
return this.updateDailyMeasures(m => ({
rebaseConflictsDialogReopenedCount:
m.rebaseConflictsDialogReopenedCount + 1,
@ -906,7 +941,7 @@ export class StatsStore implements IStatsStore {
/**
* Increments the `rebaseAbortedAfterConflictsCount` metric
*/
public async recordRebaseAbortedAfterConflicts(): Promise<void> {
public recordRebaseAbortedAfterConflicts(): Promise<void> {
return this.updateDailyMeasures(m => ({
rebaseAbortedAfterConflictsCount: m.rebaseAbortedAfterConflictsCount + 1,
}))
@ -920,10 +955,20 @@ export class StatsStore implements IStatsStore {
}))
}
/**
* Increments the `rebaseSuccessWithoutConflictsCount` metric
*/
public recordRebaseSuccessWithoutConflicts(): Promise<void> {
return this.updateDailyMeasures(m => ({
rebaseSuccessWithoutConflictsCount:
m.rebaseSuccessWithoutConflictsCount + 1,
}))
}
/**
* Increments the `rebaseSuccessAfterConflictsCount` metric
*/
public async recordRebaseSuccessAfterConflicts(): Promise<void> {
public recordRebaseSuccessAfterConflicts(): Promise<void> {
return this.updateDailyMeasures(m => ({
rebaseSuccessAfterConflictsCount: m.rebaseSuccessAfterConflictsCount + 1,
}))
@ -968,19 +1013,170 @@ export class StatsStore implements IStatsStore {
}
/** Record when a conflicted merge was successfully completed by the user */
public async recordMergeSuccessAfterConflicts(): Promise<void> {
public recordMergeSuccessAfterConflicts(): Promise<void> {
return this.updateDailyMeasures(m => ({
mergeSuccessAfterConflictsCount: m.mergeSuccessAfterConflictsCount + 1,
}))
}
/** Record when a conflicted merge was aborted by the user */
public async recordMergeAbortedAfterConflicts(): Promise<void> {
public recordMergeAbortedAfterConflicts(): Promise<void> {
return this.updateDailyMeasures(m => ({
mergeAbortedAfterConflictsCount: m.mergeAbortedAfterConflictsCount + 1,
}))
}
/** Record when the user views a stash entry after checking out a branch */
public recordStashViewedAfterCheckout(): Promise<void> {
return this.updateDailyMeasures(m => ({
stashViewedAfterCheckoutCount: m.stashViewedAfterCheckoutCount + 1,
}))
}
/** Record when the user **doesn't** view a stash entry after checking out a branch */
public recordStashNotViewedAfterCheckout(): Promise<void> {
return this.updateDailyMeasures(m => ({
stashNotViewedAfterCheckoutCount: m.stashNotViewedAfterCheckoutCount + 1,
}))
}
/** Record when the user elects to take changes to new branch over stashing */
public recordChangesTakenToNewBranch(): Promise<void> {
return this.updateDailyMeasures(m => ({
changesTakenToNewBranchCount: m.changesTakenToNewBranchCount + 1,
}))
}
/** Record when the user elects to stash changes on the current branch */
public recordStashCreatedOnCurrentBranch(): Promise<void> {
return this.updateDailyMeasures(m => ({
stashCreatedOnCurrentBranchCount: m.stashCreatedOnCurrentBranchCount + 1,
}))
}
/** Record when the user discards a stash entry */
public recordStashDiscard(): Promise<void> {
return this.updateDailyMeasures(m => ({
stashDiscardCount: m.stashDiscardCount + 1,
}))
}
/** Record when the user views a stash entry */
public recordStashView(): Promise<void> {
return this.updateDailyMeasures(m => ({
stashViewCount: m.stashViewCount + 1,
}))
}
/** Record when the user restores a stash entry */
public recordStashRestore(): Promise<void> {
return this.updateDailyMeasures(m => ({
stashRestoreCount: m.stashRestoreCount + 1,
}))
}
/** Record when the user takes no action on the stash entry */
public recordNoActionTakenOnStash(): Promise<void> {
return this.updateDailyMeasures(m => ({
noActionTakenOnStashCount: m.noActionTakenOnStashCount + 1,
}))
}
/** Record the number of stash entries created outside of Desktop for the day */
public addStashEntriesCreatedOutsideDesktop(
stashCount: number
): Promise<void> {
return this.updateDailyMeasures(m => ({
stashEntriesCreatedOutsideDesktop:
m.stashEntriesCreatedOutsideDesktop + stashCount,
}))
}
/**
* Record the number of times the user experiences the error
* "Some of your changes would be overwritten" when switching branches
*/
public recordErrorWhenSwitchingBranchesWithUncommmittedChanges(): Promise<
void
> {
return this.updateDailyMeasures(m => ({
errorWhenSwitchingBranchesWithUncommmittedChanges:
m.errorWhenSwitchingBranchesWithUncommmittedChanges + 1,
}))
}
/**
* Increment the number of times the user has opened their external editor
* from the suggested next steps view
*/
public recordSuggestedStepOpenInExternalEditor(): Promise<void> {
return this.updateDailyMeasures(m => ({
suggestedStepOpenInExternalEditor:
m.suggestedStepOpenInExternalEditor + 1,
}))
}
/**
* Increment the number of times the user has opened their repository in
* Finder/Explorer from the suggested next steps view
*/
public recordSuggestedStepOpenWorkingDirectory(): Promise<void> {
return this.updateDailyMeasures(m => ({
suggestedStepOpenWorkingDirectory:
m.suggestedStepOpenWorkingDirectory + 1,
}))
}
/**
* Increment the number of times the user has opened their repository on
* GitHub from the suggested next steps view
*/
public recordSuggestedStepViewOnGitHub(): Promise<void> {
return this.updateDailyMeasures(m => ({
suggestedStepViewOnGitHub: m.suggestedStepViewOnGitHub + 1,
}))
}
/**
* Increment the number of times the user has used the publish repository
* action from the suggested next steps view
*/
public recordSuggestedStepPublishRepository(): Promise<void> {
return this.updateDailyMeasures(m => ({
suggestedStepPublishRepository: m.suggestedStepPublishRepository + 1,
}))
}
/**
* Increment the number of times the user has used the publish branch
* action branch from the suggested next steps view
*/
public recordSuggestedStepPublishBranch(): Promise<void> {
return this.updateDailyMeasures(m => ({
suggestedStepPublishBranch: m.suggestedStepPublishBranch + 1,
}))
}
/**
* Increment the number of times the user has used the Create PR suggestion
* in the suggested next steps view.
*/
public recordSuggestedStepCreatePullRequest(): Promise<void> {
return this.updateDailyMeasures(m => ({
suggestedStepCreatePullRequest: m.suggestedStepCreatePullRequest + 1,
}))
}
/**
* Increment the number of times the user has used the View Stash suggestion
* in the suggested next steps view.
*/
public recordSuggestedStepViewStash(): Promise<void> {
return this.updateDailyMeasures(m => ({
suggestedStepViewStash: m.suggestedStepViewStash + 1,
}))
}
private onUiActivity = async () => {
this.disableUiActivityMonitoring()

View file

@ -75,7 +75,7 @@ export class AccountsStore extends TypedBaseStore<ReadonlyArray<Account>> {
/**
* Add the account to the store.
*/
public async addAccount(account: Account): Promise<void> {
public async addAccount(account: Account): Promise<Account | null> {
await this.loadingPromise
let updated = account
@ -103,12 +103,13 @@ export class AccountsStore extends TypedBaseStore<ReadonlyArray<Account>> {
} else {
this.emitError(e)
}
return
return null
}
this.accounts = [...this.accounts, updated]
this.save()
return updated
}
/** Refresh all accounts by fetching their latest info from the API. */

View file

@ -171,15 +171,16 @@ export class ApiRepositoriesStore extends BaseStore {
* the provided account has explicit permissions to access.
*/
public async loadRepositories(account: Account) {
const existingRepositories = this.accountState.get(account)
const existingAccount = resolveAccount(account, this.accountState)
const existingRepositories = this.accountState.get(existingAccount)
if (existingRepositories !== undefined && existingRepositories.loading) {
return
}
this.updateAccount(account, { loading: true })
this.updateAccount(existingAccount, { loading: true })
const api = API.fromAccount(account)
const api = API.fromAccount(existingAccount)
const repositories = await api.fetchRepositories()
if (repositories === null) {

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ import {
} from '../../models/branch'
import { Tip, TipState } from '../../models/tip'
import { Commit } from '../../models/commit'
import { IRemote } from '../../models/remote'
import { IRemote, ForkedRemotePrefix } from '../../models/remote'
import { IFetchProgress, IRevertProgress } from '../../models/progress'
import {
ICommitMessage,
@ -63,6 +63,7 @@ import {
revSymmetricDifference,
getSymbolicRef,
getConfigValue,
removeRemote,
} from '../git'
import { RetryAction, RetryActionType } from '../../models/retry-actions'
import { UpstreamAlreadyExistsError } from './upstream-already-exists-error'
@ -77,7 +78,10 @@ import { formatCommitMessage } from '../format-commit-message'
import { GitAuthor } from '../../models/git-author'
import { IGitAccount } from '../../models/git-account'
import { BaseStore } from './base-store'
import { enablePullWithRebase } from '../feature-flag'
import { enableStashing } from '../feature-flag'
import { getStashes, getStashedFiles } from '../git/stash'
import { IStashEntry, StashedChangesLoadStates } from '../../models/stash-entry'
import { PullRequest } from '../../models/pull-request'
/** The number of commits to load from history per batch. */
const CommitBatchSize = 100
@ -128,6 +132,10 @@ export class GitStore extends BaseStore {
private _lastFetched: Date | null = null
private _desktopStashEntries = new Map<string, IStashEntry>()
private _stashEntryCount = 0
public constructor(repository: Repository, shell: IAppShell) {
super()
@ -270,11 +278,7 @@ export class GitStore extends BaseStore {
this.refreshDefaultBranch()
this.refreshRecentBranches(recentBranchNames)
// no need to query Git config if this isn't displayed in the UI
if (enablePullWithRebase()) {
this.checkPullWithRebase()
}
this.checkPullWithRebase()
const commits = this._allBranches.map(b => b.tip)
@ -967,6 +971,107 @@ export class GitStore extends BaseStore {
throw new Error(`Could not load commit: '${sha}'`)
}
/**
* Refreshes the list of GitHub Desktop created stash entries for the repository
*/
public async loadStashEntries(): Promise<void> {
if (!enableStashing()) {
return
}
const map = new Map<string, IStashEntry>()
const stash = await getStashes(this.repository)
for (const entry of stash.desktopEntries) {
// we only want the first entry we find for each branch,
// so we skip all subsequent ones
if (!map.has(entry.branchName)) {
const existing = this._desktopStashEntries.get(entry.branchName)
// If we've already loaded the files for this stash there's
// no point in us doing it again. We know the contents haven't
// changed since the SHA is the same.
if (existing !== undefined && existing.stashSha === entry.stashSha) {
map.set(entry.branchName, { ...entry, files: existing.files })
} else {
map.set(entry.branchName, entry)
}
}
}
this._desktopStashEntries = map
this._stashEntryCount = stash.stashEntryCount
this.emitUpdate()
this.loadFilesForCurrentStashEntry()
}
/**
* A GitHub Desktop created stash entries for the current branch or
* null if no entry exists
*/
public get currentBranchStashEntry() {
return this._tip && this._tip.kind === TipState.Valid
? this._desktopStashEntries.get(this._tip.branch.name) || null
: null
}
/** The total number of stash entries */
public get stashEntryCount(): number {
return this._stashEntryCount
}
/** The number of stash entries created by Desktop */
public get desktopStashEntryCount(): number {
return this._desktopStashEntries.size
}
/**
* Updates the latest stash entry with a list of files that it changes
*/
private async loadFilesForCurrentStashEntry() {
if (!enableStashing()) {
return
}
const stashEntry = this.currentBranchStashEntry
if (
!stashEntry ||
stashEntry.files.kind !== StashedChangesLoadStates.NotLoaded
) {
return
}
const { branchName } = stashEntry
this._desktopStashEntries.set(branchName, {
...stashEntry,
files: { kind: StashedChangesLoadStates.Loading },
})
this.emitUpdate()
const files = await getStashedFiles(this.repository, stashEntry.stashSha)
// It's possible that we've refreshed the list of stash entries since we
// started getStashedFiles. Load the latest entry for the branch and make
// sure the SHAs match up.
const currentEntry = this._desktopStashEntries.get(branchName)
if (!currentEntry || currentEntry.stashSha !== stashEntry.stashSha) {
return
}
this._desktopStashEntries.set(branchName, {
...currentEntry,
files: {
kind: StashedChangesLoadStates.Loaded,
files,
},
})
this.emitUpdate()
}
public async loadRemotes(): Promise<void> {
const remotes = await getRemotes(this.repository)
this._defaultRemote = findDefaultRemote(remotes)
@ -1334,4 +1439,21 @@ export class GitStore extends BaseStore {
behind: aheadBehind.behind,
}
}
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)
}
}
for (const r of remotes) {
if (r.name.startsWith(ForkedRemotePrefix) && !prRemotes.has(r.url)) {
await removeRemote(this.repository, r.name)
}
}
}
}

View file

@ -4,11 +4,11 @@ import { Branch } from '../../../models/branch'
import { GitStoreCache } from '../git-store-cache'
import {
getMergedBranches,
getCheckoutsAfterDate,
getBranchCheckouts,
getSymbolicRef,
IMergedBranch,
formatAsLocalRef,
deleteLocalBranch,
getBranches,
} from '../../git'
import { fatalError } from '../../fatal-error'
import { RepositoryStateCache } from '../repository-state-cache'
@ -28,6 +28,31 @@ const ReservedRefs = [
'refs/heads/release',
]
/**
* Behavior flags for the branch prune execution, to aid with testing and
* verifying locally.
*/
type PruneRuntimeOptions = {
/**
* By default the branch pruner will only run every 24 hours
*
* Set this flag to `false` to ignore this check.
*/
readonly enforcePruneThreshold: boolean
/**
* By default the branch pruner will also delete the branches it believes can
* be pruned safely.
*
* Set this to `false` to keep these in your repository.
*/
readonly deleteBranch: boolean
}
const DefaultPruneOptions: PruneRuntimeOptions = {
enforcePruneThreshold: true,
deleteBranch: true,
}
export class BranchPruner {
private timer: number | null = null
@ -48,9 +73,9 @@ export class BranchPruner {
)
}
await this.pruneLocalBranches()
await this.pruneLocalBranches(DefaultPruneOptions)
this.timer = window.setInterval(
() => this.pruneLocalBranches(),
() => this.pruneLocalBranches(DefaultPruneOptions),
BackgroundPruneMinimumInterval
)
}
@ -64,31 +89,47 @@ export class BranchPruner {
this.timer = null
}
public async testPrune(): Promise<void> {
return this.pruneLocalBranches({
enforcePruneThreshold: false,
deleteBranch: false,
})
}
/** @returns a map of canonical refs to their shas */
private async findBranchesMergedIntoDefaultBranch(
repository: Repository,
defaultBranch: Branch
): Promise<ReadonlyArray<IMergedBranch>> {
): Promise<ReadonlyMap<string, string>> {
const gitStore = this.gitStoreCache.get(repository)
const mergedBranches = await gitStore.performFailableOperation(() =>
getMergedBranches(repository, defaultBranch.name)
)
if (mergedBranches === undefined) {
return []
return new Map<string, string>()
}
const currentBranchCanonicalRef = await getSymbolicRef(repository, 'HEAD')
// remove the current branch
return currentBranchCanonicalRef === null
? mergedBranches
: mergedBranches.filter(
mb => mb.canonicalRef !== currentBranchCanonicalRef
)
if (currentBranchCanonicalRef) {
mergedBranches.delete(currentBranchCanonicalRef)
}
return mergedBranches
}
private async pruneLocalBranches(): Promise<void> {
if (this.repository.gitHubRepository === null) {
/**
* Prune the local branches for the repository
*
* @param options configure the behaviour of the branch pruning process
*/
private async pruneLocalBranches(
options: PruneRuntimeOptions
): Promise<void> {
const { gitHubRepository } = this.repository
if (gitHubRepository === null) {
return
}
@ -103,18 +144,28 @@ export class BranchPruner {
// Using type coelescing behavior to deal with Dexie returning `undefined`
// for records that haven't been updated with the new field yet
if (lastPruneDate != null && threshold.isBefore(lastPruneDate)) {
if (
options.enforcePruneThreshold &&
lastPruneDate != null &&
threshold.isBefore(lastPruneDate)
) {
log.info(
`Last prune took place ${moment(lastPruneDate).from(
`[BranchPruner] Last prune took place ${moment(lastPruneDate).from(
dateNow
)} - skipping`
)
return
}
// update the last prune date first thing after we check it!
await this.repositoriesStore.updateLastPruneDate(
this.repository,
Date.now()
)
// Get list of branches that have been merged
const { branchesState } = this.repositoriesStateCache.get(this.repository)
const { defaultBranch } = branchesState
const { defaultBranch, allBranches } = branchesState
if (defaultBranch === null) {
return
@ -125,8 +176,8 @@ export class BranchPruner {
defaultBranch
)
if (mergedBranches.length === 0) {
log.info('No branches to prune.')
if (mergedBranches.size === 0) {
log.info('[BranchPruner] No branches to prune.')
return
}
@ -134,7 +185,7 @@ export class BranchPruner {
const twoWeeksAgo = moment()
.subtract(2, 'weeks')
.toDate()
const recentlyCheckedOutBranches = await getCheckoutsAfterDate(
const recentlyCheckedOutBranches = await getBranchCheckouts(
this.repository,
twoWeeksAgo
)
@ -142,17 +193,31 @@ export class BranchPruner {
[...recentlyCheckedOutBranches.keys()].map(formatAsLocalRef)
)
// Create array of branches that can be pruned
const candidateBranches = mergedBranches.filter(
mb => !ReservedRefs.includes(mb.canonicalRef)
)
// get the locally cached branches of remotes (ie `remotes/origin/master`)
const remoteBranches = (await getBranches(
this.repository,
`refs/remotes/`
)).map(b => formatAsLocalRef(b.name))
const branchesReadyForPruning = candidateBranches.filter(
mb => !recentlyCheckedOutCanonicalRefs.has(mb.canonicalRef)
// create list of branches to be pruned
const branchesReadyForPruning = Array.from(mergedBranches.keys()).filter(
ref => {
if (ReservedRefs.includes(ref)) {
return false
}
if (recentlyCheckedOutCanonicalRefs.has(ref)) {
return false
}
const upstreamRef = getUpstreamRefForLocalBranchRef(ref, allBranches)
if (upstreamRef === undefined) {
return false
}
return !remoteBranches.includes(upstreamRef)
}
)
log.info(
`Pruning ${
`[BranchPruner] Pruning ${
branchesReadyForPruning.length
} branches that have been merged into the default branch, ${
defaultBranch.name
@ -162,26 +227,51 @@ export class BranchPruner {
const gitStore = this.gitStoreCache.get(this.repository)
const branchRefPrefix = `refs/heads/`
for (const branch of branchesReadyForPruning) {
if (!branch.canonicalRef.startsWith(branchRefPrefix)) {
for (const branchCanonicalRef of branchesReadyForPruning) {
if (!branchCanonicalRef.startsWith(branchRefPrefix)) {
continue
}
const branchName = branch.canonicalRef.substr(branchRefPrefix.length)
const branchName = branchCanonicalRef.substr(branchRefPrefix.length)
const isDeleted = await gitStore.performFailableOperation(() =>
deleteLocalBranch(this.repository, branchName)
)
if (options.deleteBranch) {
const isDeleted = await gitStore.performFailableOperation(() =>
deleteLocalBranch(this.repository, branchName)
)
if (isDeleted) {
log.info(`Pruned branch ${branchName} (was ${branch.sha})`)
if (isDeleted) {
log.info(
`[BranchPruner] Pruned branch ${branchName} ((was ${mergedBranches.get(
branchCanonicalRef
)}))`
)
}
} else {
log.info(`[BranchPruner] Branch '${branchName}' marked for deletion`)
}
}
await this.repositoriesStore.updateLastPruneDate(
this.repository,
Date.now()
)
this.onPruneCompleted(this.repository)
}
}
/**
* @param ref the canonical ref for a local branch
* @param allBranches a list of all branches in the Repository model
* @returns the canonical upstream branch ref or undefined if upstream can't be reliably determined
*/
function getUpstreamRefForLocalBranchRef(
ref: string,
allBranches: ReadonlyArray<Branch>
): string | undefined {
const branch = allBranches.find(b => formatAsLocalRef(b.name) === ref)
// if we can't find a branch model, we can't determine the ref's upstream
if (branch === undefined) {
return undefined
}
const { upstream } = branch
// if there's no upstream in the branch, there's nothing to lookup
if (upstream === null) {
return undefined
}
return formatAsLocalRef(upstream)
}

View file

@ -0,0 +1,33 @@
import { Tip, TipState } from '../../../models/tip'
import { IRemote } from '../../../models/remote'
import { GitHubRepository } from '../../../models/github-repository'
import { urlMatchesCloneURL } from '../../repository-matching'
/**
* Function to determine which branch name to use when looking for branch
* protection information.
*
* If the remote branch matches the current `githubRepository` associated with
* the repostiory, this will be used. Otherwise we will fall back to using the
* branch name as that's a reasonable approximation for what would happen if the
* user tries to push the new branch.
*/
export function findRemoteBranchName(
tip: Tip,
remote: IRemote | null,
gitHubRepository: GitHubRepository
): string | null {
if (tip.kind !== TipState.Valid) {
return null
}
if (
tip.branch.upstreamWithoutRemote !== null &&
remote !== null &&
urlMatchesCloneURL(remote.url, gitHubRepository)
) {
return tip.branch.upstreamWithoutRemote
}
return tip.branch.nameWithoutRemote
}

View file

@ -1,65 +1,80 @@
import { PullRequestStore } from '../pull-request-store'
import { Account } from '../../../models/account'
import { fatalError } from '../../fatal-error'
import { Repository } from '../../../models/repository'
import { GitHubRepository } from '../../../models/github-repository'
//** Interval to check for pull requests */
const PullRequestInterval = 1000 * 60 * 10
enum TimeoutHandles {
PullRequest = 'PullRequestHandle',
Status = 'StatusHandle',
PushedPullRequest = 'PushedPullRequestHandle',
}
/** Check for new or updated pull requests every 30 minutes */
const PullRequestInterval = 30 * 60 * 1000
/**
* Acts as a service for downloading the latest pull request
* and status info from GitHub.
* Never check for new or updated pull requests more
* frequently than every 2 minutes
*/
const MaxPullRequestRefreshFrequency = 2 * 60 * 1000
/**
* Periodically requests a refresh of the list of open pull requests
* for a particular GitHub repository. The intention is for the
* updater to only run when the app is in focus. When the updater
* is started (in other words when the app is focused) it will
* refresh the list of open pull requests as soon as possible while
* ensuring that we never update more frequently than the value
* indicated by the `MaxPullRequestRefreshFrequency` variable.
*/
export class PullRequestUpdater {
private readonly repository: Repository
private readonly account: Account
private readonly store: PullRequestStore
private readonly timeoutHandles = new Map<TimeoutHandles, number>()
private isStopped: boolean = true
private timeoutId: number | null = null
private running = false
public constructor(
repository: Repository,
account: Account,
pullRequestStore: PullRequestStore
) {
this.repository = repository
this.account = account
this.store = pullRequestStore
}
private readonly repository: GitHubRepository,
private readonly account: Account,
private readonly store: PullRequestStore
) {}
/** Starts the updater */
public start() {
if (!this.isStopped) {
fatalError(
'Cannot start the Pull Request Updater that is already running.'
)
if (!this.running) {
this.running = true
this.scheduleTick(MaxPullRequestRefreshFrequency)
}
}
private getTimeSinceLastRefresh() {
const lastRefreshed = this.store.getLastRefreshed(this.repository)
const timeSince =
lastRefreshed === undefined ? Infinity : Date.now() - lastRefreshed
return timeSince
}
private scheduleTick(timeout: number = PullRequestInterval) {
if (this.running) {
const due = Math.max(timeout - this.getTimeSinceLastRefresh(), 0)
this.timeoutId = window.setTimeout(() => this.tick(), due)
}
}
private tick() {
if (!this.running) {
return
}
this.timeoutHandles.set(
TimeoutHandles.PullRequest,
this.timeoutId = null
if (this.getTimeSinceLastRefresh() < MaxPullRequestRefreshFrequency) {
this.scheduleTick()
}
window.setTimeout(() => {
this.store.fetchAndCachePullRequests(this.repository, this.account)
}, PullRequestInterval)
)
this.store
.refreshPullRequests(this.repository, this.account)
.catch(() => {})
.then(() => this.scheduleTick())
}
public stop() {
this.isStopped = true
for (const timeoutHandle of this.timeoutHandles.values()) {
window.clearTimeout(timeoutHandle)
if (this.running) {
if (this.timeoutId !== null) {
window.clearTimeout(this.timeoutId)
this.timeoutId = null
}
this.running = false
}
this.timeoutHandles.clear()
}
}

View file

@ -1,229 +1,334 @@
import { PullRequestDatabase, IPullRequest } from '../databases'
import mem from 'mem'
import {
PullRequestDatabase,
IPullRequest,
PullRequestKey,
getPullRequestKey,
} from '../databases/pull-request-database'
import { GitHubRepository } from '../../models/github-repository'
import { Account } from '../../models/account'
import { API, IAPIPullRequest } from '../api'
import { fatalError, forceUnwrap } from '../fatal-error'
import { API, IAPIPullRequest, MaxResultsError } from '../api'
import { fatalError } from '../fatal-error'
import { RepositoriesStore } from './repositories-store'
import { PullRequest, PullRequestRef } from '../../models/pull-request'
import { TypedBaseStore } from './base-store'
import { Repository } from '../../models/repository'
import { getRemotes, removeRemote } from '../git'
import { IRemote, ForkedRemotePrefix } from '../../models/remote'
const Decrement = (n: number) => n - 1
const Increment = (n: number) => n + 1
import { structuralEquals } from '../equality'
import { Emitter, Disposable } from 'event-kit'
import { APIError } from '../http'
/** The store for GitHub Pull Requests. */
export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
private readonly pullRequestDatabase: PullRequestDatabase
private readonly repositoryStore: RepositoriesStore
private readonly activeFetchCountPerRepository = new Map<number, number>()
export class PullRequestStore {
protected readonly emitter = new Emitter()
private readonly currentRefreshOperations = new Map<number, Promise<void>>()
private readonly lastRefreshForRepository = new Map<number, number>()
public constructor(
db: PullRequestDatabase,
repositoriesStore: RepositoriesStore
) {
super()
private readonly db: PullRequestDatabase,
private readonly repositoryStore: RepositoriesStore
) {}
this.pullRequestDatabase = db
this.repositoryStore = repositoriesStore
private emitPullRequestsChanged(
repository: GitHubRepository,
pullRequests: ReadonlyArray<PullRequest>
) {
this.emitter.emit('onPullRequestsChanged', { repository, pullRequests })
}
/** Register a function to be called when the store updates. */
public onPullRequestsChanged(
fn: (
repository: GitHubRepository,
pullRequests: ReadonlyArray<PullRequest>
) => void
): Disposable {
return this.emitter.on('onPullRequestsChanged', value => {
const { repository, pullRequests } = value
fn(repository, pullRequests)
})
}
private emitIsLoadingPullRequests(
repository: GitHubRepository,
isLoadingPullRequests: boolean
) {
this.emitter.emit('onIsLoadingPullRequest', {
repository,
isLoadingPullRequests,
})
}
/** Register a function to be called when the store updates. */
public onIsLoadingPullRequests(
fn: (repository: GitHubRepository, isLoadingPullRequests: boolean) => void
): Disposable {
return this.emitter.on('onIsLoadingPullRequest', value => {
const { repository, isLoadingPullRequests } = value
fn(repository, isLoadingPullRequests)
})
}
/** Loads all pull requests against the given repository. */
public async fetchAndCachePullRequests(
repository: 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)
if (currentOp !== undefined) {
return currentOp
}
this.lastRefreshForRepository.set(dbId, Date.now())
this.emitIsLoadingPullRequests(repo, true)
const promise = this.fetchAndStorePullRequests(repo, account)
.catch(err => {
log.error(`Error refreshing pull requests for '${repo.fullName}'`, err)
})
.then(() => {
this.currentRefreshOperations.delete(dbId)
this.emitIsLoadingPullRequests(repo, false)
})
this.currentRefreshOperations.set(dbId, promise)
return promise
}
/**
* Fetches pull requests from the API (either all open PRs if it's the
* first time fetching for this repository or all updated PRs if not).
*
* Returns a value indicating whether it's safe to avoid
* emitting an event that the store has been updated. In other words, when
* this method returns false it's safe to say that nothing has been changed
* in the pull requests table.
*/
private async fetchAndStorePullRequests(
repo: GitHubRepository,
account: Account
): Promise<void> {
const githubRepo = forceUnwrap(
'Can only refresh pull requests for GitHub repositories',
repository.gitHubRepository
)
const apiClient = API.fromAccount(account)
) {
const api = API.fromAccount(account)
const lastUpdatedAt = await this.db.getLastUpdated(repo)
this.updateActiveFetchCount(githubRepo, Increment)
try {
const apiResult = await apiClient.fetchPullRequests(
githubRepo.owner.login,
githubRepo.name,
'open'
)
await this.cachePullRequests(apiResult, githubRepo)
const prs = await this.fetchPullRequestsFromCache(githubRepo)
await this.pruneForkedRemotes(repository, prs)
this.emitUpdate(githubRepo)
} catch (error) {
log.warn(`Error refreshing pull requests for '${repository.name}'`, error)
} finally {
this.updateActiveFetchCount(githubRepo, Decrement)
// If we don't have a lastUpdatedAt that mean we haven't fetched any PRs
// for the repository yet which in turn means we only have to fetch the
// currently open PRs. If we have fetched before we get all PRs
// If we have a lastUpdatedAt that mean we have fetched PRs
// for the repository before. If we have fetched before we get all PRs
// that have been modified since the last time we fetched so that we
// can prune closed issues from our database. Note that since
// `api.fetchUpdatedPullRequests` returns all issues modified _at_ or
// after the timestamp we give it we will always get at least one issue
// back. See `storePullRequests` for details on how that's handled.
if (!lastUpdatedAt) {
return this.fetchAndStoreOpenPullRequests(api, repo)
} else {
return this.fetchAndStoreUpdatedPullRequests(api, repo, lastUpdatedAt)
}
}
/** Is the store currently fetching the list of open pull requests? */
public isFetchingPullRequests(repository: GitHubRepository): boolean {
const repoDbId = forceUnwrap(
'Cannot fetch PRs for a repository which is not in the database',
repository.dbID
)
const currentCount = this.activeFetchCountPerRepository.get(repoDbId) || 0
return currentCount > 0
}
/** Gets the pull requests against the given repository. */
public async fetchPullRequestsFromCache(
private async fetchAndStoreOpenPullRequests(
api: API,
repository: GitHubRepository
): Promise<ReadonlyArray<PullRequest>> {
const gitHubRepositoryID = repository.dbID
) {
const { name, owner } = getNameWithOwner(repository)
const open = await api.fetchAllOpenPullRequests(owner, name)
await this.storePullRequestsAndEmitUpdate(open, repository)
}
if (gitHubRepositoryID == null) {
return fatalError(
"Cannot get pull requests for a repository that hasn't been inserted into the database!"
private async fetchAndStoreUpdatedPullRequests(
api: API,
repository: GitHubRepository,
lastUpdatedAt: Date
) {
const { name, owner } = getNameWithOwner(repository)
const updated = await api
.fetchUpdatedPullRequests(owner, name, lastUpdatedAt)
.catch(e =>
// Any other error we'll bubble up but these ones we
// can handle, see below.
e instanceof MaxResultsError || e instanceof APIError
? Promise.resolve(null)
: Promise.reject(e)
)
if (updated !== null) {
return await this.storePullRequestsAndEmitUpdate(updated, repository)
} else {
// If we fail to load updated pull requests either because
// there's too many updated PRs since the last time we
// fetched (and it's likely that it'll be much more
// efficient to just load the open PRs) or it's because the
// API told us we couldn't load PRs (rate limit or permissions
// problems). In either case we delete the PRs we've got
// for this repo and attempt to load just the open ones.
//
// This scenario can happen for repositories that are
// very active while simultaneously infrequently used
// by the user. Think of a very active open source repository
// where the user only visits once a year to make a contribution.
// It's likely that there's at most a few hundred PRs open but
// the number of merged PRs since the last time we fetched could
// number in the thousands.
await this.db.deleteAllPullRequestsInRepository(repository)
await this.fetchAndStoreOpenPullRequests(api, repository)
}
}
public getLastRefreshed(repository: GitHubRepository) {
return repository.dbID
? this.lastRefreshForRepository.get(repository.dbID)
: undefined
}
/** 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.pullRequestDatabase.pullRequests
.where('base.repoId')
.equals(gitHubRepositoryID)
.reverse()
.sortBy('number')
const records = await this.db.getAllPullRequestsInRepository(repository)
const result = new Array<PullRequest>()
for (const record of records) {
const repositoryDbId = record.head.repoId
let githubRepository: GitHubRepository | null = null
// In order to avoid what would otherwise be a very expensive
// N+1 (N+2 really) query where we look up the head and base
// GitHubRepository from IndexedDB for each pull request we'll memoize
// already retrieved GitHubRepository instances.
//
// This optimization decreased the run time of this method from 6
// seconds to just under 26 ms while testing using an internal
// repository with 1k+ PRs. Even in the worst-case scenario (i.e
// a repository with a huge number of open PRs from forks) this
// will reduce the N+2 to N+1.
const store = this.repositoryStore
const getRepo = mem(store.findGitHubRepositoryByID.bind(store))
if (repositoryDbId != null) {
githubRepository = await this.repositoryStore.findGitHubRepositoryByID(
repositoryDbId
)
for (const record of records) {
const headRepository = await getRepo(record.head.repoId)
const baseRepository = await getRepo(record.base.repoId)
if (headRepository === null) {
return fatalError("head repository can't be null")
}
// We know the base repo ID can't be null since it's the repository we
// fetched the PR from in the first place.
const parentRepositoryDbId = forceUnwrap(
'A pull request cannot have a null base repo id',
record.base.repoId
)
const parentGitGubRepository: GitHubRepository | null = await this.repositoryStore.findGitHubRepositoryByID(
parentRepositoryDbId
)
const parentGitHubRepository = forceUnwrap(
'PR cannot have a null base repo',
parentGitGubRepository
)
// We can be certain the PR ID is valid since we just got it from the
// database.
const pullRequestDbId = forceUnwrap(
'PR cannot have a null ID after being retrieved from the database',
record.id
)
if (baseRepository === null) {
return fatalError("base repository can't be null")
}
result.push(
new PullRequest(
pullRequestDbId,
new Date(record.createdAt),
record.title,
record.number,
new PullRequestRef(
record.head.ref,
record.head.sha,
githubRepository
),
new PullRequestRef(
record.base.ref,
record.base.sha,
parentGitHubRepository
),
new PullRequestRef(record.head.ref, record.head.sha, headRepository),
new PullRequestRef(record.base.ref, record.base.sha, baseRepository),
record.author
)
)
}
return result
// Reversing the results in place manually instead of using
// .reverse on the IndexedDB query has been measured to have favorable
// performance characteristics for repositories with a lot of pull
// requests since it means Dexie is able to leverage the IndexedDB
// getAll method as opposed to creating a reverse cursor. Reversing
// in place versus unshifting is also dramatically more performant.
return result.reverse()
}
private async pruneForkedRemotes(
repository: Repository,
pullRequests: ReadonlyArray<PullRequest>
) {
const remotes = await getRemotes(repository)
const forkedRemotesToDelete = this.getRemotesToDelete(remotes, pullRequests)
await this.deleteRemotes(repository, forkedRemotesToDelete)
}
private getRemotesToDelete(
remotes: ReadonlyArray<IRemote>,
openPullRequests: ReadonlyArray<PullRequest>
): ReadonlyArray<IRemote> {
const forkedRemotes = remotes.filter(remote =>
remote.name.startsWith(ForkedRemotePrefix)
)
const remotesOfPullRequests = new Set<string>()
openPullRequests.forEach(pr => {
const { gitHubRepository } = pr.head
if (gitHubRepository != null && gitHubRepository.cloneURL != null) {
remotesOfPullRequests.add(gitHubRepository.cloneURL)
}
})
const result = forkedRemotes.filter(
forkedRemote => !remotesOfPullRequests.has(forkedRemote.url)
)
return result
}
private async deleteRemotes(
repository: Repository,
remotes: ReadonlyArray<IRemote>
) {
const promises: Array<Promise<void>> = []
remotes.forEach(r => promises.push(removeRemote(repository, r.name)))
await Promise.all(promises)
}
private updateActiveFetchCount(
repository: GitHubRepository,
update: (count: number) => number
) {
const repoDbId = forceUnwrap(
'Cannot fetch PRs for a repository which is not in the database',
repository.dbID
)
const currentCount = this.activeFetchCountPerRepository.get(repoDbId) || 0
const newCount = update(currentCount)
this.activeFetchCountPerRepository.set(repoDbId, newCount)
this.emitUpdate(repository)
}
private async cachePullRequests(
/**
* Stores all pull requests that are open and deletes all that are merged
* or closed. Returns a value indicating whether an update notification
* has been emitted, see `storePullRequests` for more details.
*/
private async storePullRequestsAndEmitUpdate(
pullRequestsFromAPI: ReadonlyArray<IAPIPullRequest>,
repository: GitHubRepository
): Promise<void> {
const repoDbId = repository.dbID
) {
if (await this.storePullRequests(pullRequestsFromAPI, repository)) {
this.emitPullRequestsChanged(repository, await this.getAll(repository))
}
}
if (repoDbId == null) {
return fatalError(
"Cannot store pull requests for a repository that hasn't been inserted into the database!"
)
/**
* Stores all pull requests that are open and deletes all that are merged
* or closed. Returns a value indicating whether it's safe to avoid
* emitting an event that the store has been updated. In other words, when
* this method returns false it's safe to say that nothing has been changed
* in the pull requests table.
*/
private async storePullRequests(
pullRequestsFromAPI: ReadonlyArray<IAPIPullRequest>,
repository: GitHubRepository
) {
if (pullRequestsFromAPI.length === 0) {
return false
}
const table = this.pullRequestDatabase.pullRequests
const prsToInsert = new Array<IPullRequest>()
let mostRecentlyUpdated = pullRequestsFromAPI[0].updated_at
const prsToDelete = new Array<PullRequestKey>()
const prsToUpsert = new Array<IPullRequest>()
// The API endpoint for this PR, i.e api.github.com or a GHE url
const { endpoint } = repository
const store = this.repositoryStore
// Upsert will always query the database for a repository. Given that
// we've receive these repositories in a batch response from the API
// it's pretty unlikely that they'd differ between PRs so we're going
// 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), {
// 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
// the raw object that we got from the API which could consist of
// more than just the fields we've modelled in the interface. The
// only thing we really care about to determine whether the
// repository has already been inserted in the database is the clone
// url since that's what the upsert method uses as its key.
cacheKey: (_, repo) => repo.clone_url,
})
for (const pr of pullRequestsFromAPI) {
// We can do this string comparison here rather than convert to date
// because ISO8601 is lexicographically sortable
if (pr.updated_at > mostRecentlyUpdated) {
mostRecentlyUpdated = pr.updated_at
}
// We know the base repo isn't null since that's where we got the PR from
// in the first place.
if (pr.base.repo === null) {
return fatalError('PR cannot have a null base repo')
}
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
}
// `pr.head.repo` represents the source of the pull request. It might be
// a branch associated with the current repository, or a fork of the
// current repository.
@ -231,71 +336,73 @@ export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
// In cases where the user has removed the fork of the repository after
// opening a pull request, this can be `null`, and the app will not store
// this pull request.
if (pr.head.repo == null) {
log.debug(
`Unable to store pull request #${pr.number} for repository ${
repository.fullName
} as it has no head repository associated with it`
)
prsToDelete.push(getPullRequestKey(baseGitHubRepo, pr.number))
continue
}
const githubRepo = await this.repositoryStore.upsertGitHubRepository(
repository.endpoint,
pr.head.repo
)
const headRepo = await upsertRepo(endpoint, pr.head.repo)
const githubRepoDbId = forceUnwrap(
'PR cannot have non-existent repo',
githubRepo.dbID
)
if (headRepo.dbID === null) {
return fatalError('PR cannot have non-existent repo')
}
// We know the base repo isn't null since that's where we got the PR from
// in the first place.
const parentRepo = forceUnwrap(
'PR cannot have a null base repo',
pr.base.repo
)
const parentGitHubRepo = await this.repositoryStore.upsertGitHubRepository(
repository.endpoint,
parentRepo
)
const parentGitHubRepoDbId = forceUnwrap(
'PR cannot have a null parent database id',
parentGitHubRepo.dbID
)
prsToInsert.push({
prsToUpsert.push({
number: pr.number,
title: pr.title,
createdAt: pr.created_at,
updatedAt: pr.updated_at,
head: {
ref: pr.head.ref,
sha: pr.head.sha,
repoId: githubRepoDbId,
repoId: headRepo.dbID,
},
base: {
ref: pr.base.ref,
sha: pr.base.sha,
repoId: parentGitHubRepoDbId,
repoId: baseGitHubRepo.dbID,
},
author: pr.user.login,
})
}
return this.pullRequestDatabase.transaction('rw', table, async () => {
// we need to delete the stales PRs from the db
// so we remove all for a repo to avoid having to
// do diffing
await table
.where('base.repoId')
.equals(repoDbId)
.delete()
// When loading only PRs that has changed since the last fetch
// we get back all PRs modified _at_ or after the timestamp we give it
// meaning we will always get at least one issue back but. This
// check detect this particular condition and lets us avoid expensive
// branch pruning and updates for a single PR that hasn't actually
// been updated.
if (prsToDelete.length === 0 && prsToUpsert.length === 1) {
const cur = prsToUpsert[0]
const prev = await this.db.getPullRequest(repository, cur.number)
if (prsToInsert.length > 0) {
await table.bulkAdd(prsToInsert)
if (prev !== undefined && structuralEquals(cur, prev)) {
return false
}
})
}
await this.db.transaction(
'rw',
this.db.pullRequests,
this.db.pullRequestsLastUpdated,
async () => {
await this.db.deletePullRequests(prsToDelete)
await this.db.putPullRequests(prsToUpsert)
await this.db.setLastUpdated(repository, new Date(mostRecentlyUpdated))
}
)
return true
}
}
function getNameWithOwner(repository: GitHubRepository) {
const owner = repository.owner.login
const name = repository.name
return { name, owner }
}

View file

@ -2,18 +2,35 @@ import {
RepositoriesDatabase,
IDatabaseGitHubRepository,
IDatabaseOwner,
IDatabaseProtectedBranch,
} from '../databases/repositories-database'
import { Owner } from '../../models/owner'
import { GitHubRepository } from '../../models/github-repository'
import { Repository } from '../../models/repository'
import { fatalError } from '../fatal-error'
import { IAPIRepository } from '../api'
import { IAPIRepository, IAPIBranch } from '../api'
import { BaseStore } from './base-store'
import { enableBranchProtectionChecks } from '../feature-flag'
/** The store for local repositories. */
export class RepositoriesStore extends BaseStore {
private db: RepositoriesDatabase
// Key-repo ID, Value-date
private lastStashCheckCache = new Map<number, number>()
/**
* Key is the GitHubRepository id, value is the protected branch count reported
* by the GitHub API.
*/
private branchProtectionSettingsFoundCache = new Map<number, boolean>()
/**
* Key is the lookup by the GitHubRepository id and branch name, value is the
* flag whether this branch is considered protected by the GitHub API
*/
private protectionEnabledForBranchCache = new Map<string, boolean>()
public constructor(db: RepositoriesDatabase) {
super()
@ -147,6 +164,7 @@ export class RepositoriesStore extends BaseStore {
path,
gitHubRepositoryID: null,
missing: false,
lastStashCheckDate: null,
})
}
@ -181,11 +199,16 @@ export class RepositoriesStore extends BaseStore {
const gitHubRepositoryID = repository.gitHubRepository
? repository.gitHubRepository.dbID
: null
const oldRecord = await this.db.repositories.get(repoID)
const lastStashCheckDate =
oldRecord !== undefined ? oldRecord.lastStashCheckDate : null
await this.db.repositories.put({
id: repository.id,
path: repository.path,
missing,
gitHubRepositoryID,
lastStashCheckDate,
})
this.emitUpdate()
@ -213,11 +236,16 @@ export class RepositoriesStore extends BaseStore {
const gitHubRepositoryID = repository.gitHubRepository
? repository.gitHubRepository.dbID
: null
const oldRecord = await this.db.repositories.get(repoID)
const lastStashCheckDate =
oldRecord !== undefined ? oldRecord.lastStashCheckDate : null
await this.db.repositories.put({
id: repository.id,
missing: false,
path: path,
path,
gitHubRepositoryID,
lastStashCheckDate,
})
this.emitUpdate()
@ -230,6 +258,69 @@ export class RepositoriesStore extends BaseStore {
)
}
/**
* Sets the last time the repository was checked for stash entries
*
* @param repository The repository in which to update the last stash check date for
* @param date The date and time in which the last stash check took place; defaults to
* the current time
*/
public async updateLastStashCheckDate(
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, {
lastStashCheckDate: date,
})
this.lastStashCheckCache.set(repoID, date)
this.emitUpdate()
}
/**
* Gets the last time the repository was checked for stash entries
*
* @param repository The repository in which to update the last stash check date for
*/
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
if (lastCheckDate !== null) {
return lastCheckDate
}
const record = await this.db.repositories.get(repoID)
if (record === undefined) {
return fatalError(
`'getLastStashCheckDate' - unable to find repository with ID: ${repoID}`
)
}
lastCheckDate = record.lastStashCheckDate
if (lastCheckDate !== null) {
this.lastStashCheckCache.set(repoID, lastCheckDate)
}
return lastCheckDate
}
private async putOwner(endpoint: string, login: string): Promise<Owner> {
login = login.toLowerCase()
@ -336,6 +427,69 @@ export class RepositoriesStore extends BaseStore {
)
}
/** Add or update the branch protections associated with a GitHub repository. */
public async updateBranchProtections(
gitHubRepository: GitHubRepository,
protectedBranches: ReadonlyArray<IAPIBranch>
): Promise<void> {
if (!enableBranchProtectionChecks()) {
return
}
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:
//
// - update the in-memory cache
// - update the underyling database state
//
// This should ensure any stale values are not being used, and avoids
// the need to query the database while the results are in memory.
const prefix = getKeyPrefix(dbID)
for (const key of this.protectionEnabledForBranchCache.keys()) {
// invalidate any cached entries belonging to this repository
if (key.startsWith(prefix)) {
this.protectionEnabledForBranchCache.delete(key)
}
}
const branchRecords = protectedBranches.map<IDatabaseProtectedBranch>(
b => ({
repoId: dbID,
name: b.name,
})
)
// update cached values to avoid database lookup
for (const item of branchRecords) {
const key = getKey(dbID, item.name)
this.protectionEnabledForBranchCache.set(key, true)
}
await this.db.protectedBranches
.where('repoId')
.equals(dbID)
.delete()
const protectionsFound = branchRecords.length > 0
this.branchProtectionSettingsFoundCache.set(dbID, protectionsFound)
if (branchRecords.length > 0) {
await this.db.protectedBranches.bulkAdd(branchRecords)
}
})
this.emitUpdate()
}
/**
* Set's the last time the repository was checked for pruning
*
@ -408,4 +562,97 @@ export class RepositoriesStore extends BaseStore {
return record!.lastPruneDate
}
/**
* Load the branch protection information for a repository from the database
* and cache the results in memory
*/
private async loadAndCacheBranchProtection(dbID: number) {
// query the database to find any protected branches
const branches = await this.db.protectedBranches
.where('repoId')
.equals(dbID)
.toArray()
const branchProtectionsFound = branches.length > 0
this.branchProtectionSettingsFoundCache.set(dbID, branchProtectionsFound)
// fill the retrieved records into the per-branch cache
for (const branch of branches) {
const key = getKey(dbID, branch.name)
this.protectionEnabledForBranchCache.set(key, true)
}
return branchProtectionsFound
}
/**
* Check if any branch protection settings are enabled for the repository
* through the GitHub API.
*/
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
)
if (branchProtectionsFound === undefined) {
return this.loadAndCacheBranchProtection(dbID)
}
return branchProtectionsFound
}
/**
* Check if the given branch for the repository is protected through the
* GitHub API.
*/
public async isBranchProtectedOnRemote(
gitHubRepository: GitHubRepository,
branchName: string
): Promise<boolean> {
if (gitHubRepository.dbID === null) {
return fatalError(
'unable to get protected branches, GitHub repository has a null dbID'
)
}
const { dbID } = gitHubRepository
const key = getKey(dbID, branchName)
const cachedProtectionValue = this.protectionEnabledForBranchCache.get(key)
if (cachedProtectionValue === true) {
return cachedProtectionValue
}
const databaseValue = await this.db.protectedBranches.get([
dbID,
branchName,
])
// if no row found, this means no protection is found for the branch
const value = databaseValue !== undefined
this.protectionEnabledForBranchCache.set(key, value)
return value
}
}
/** Compute the key for the branch protection cache */
function getKey(dbID: number, branchName: string) {
return `${getKeyPrefix(dbID)}${branchName}`
}
/** Compute the key prefix for the branch protection cache */
function getKeyPrefix(dbID: number) {
return `${dbID}-`
}

View file

@ -16,6 +16,8 @@ import {
IRepositoryState,
RepositorySectionTab,
ICommitSelection,
IRebaseState,
ChangesSelectionKind,
} from '../app-state'
import { ComparisonCache } from '../comparison-cache'
import { IGitHubUser } from '../databases'
@ -97,6 +99,17 @@ export class RepositoryStateCache {
return { branchesState: newState }
})
}
public updateRebaseState<K extends keyof IRebaseState>(
repository: Repository,
fn: (branchesState: IRebaseState) => Pick<IRebaseState, K>
) {
this.update(repository, state => {
const { rebaseState } = state
const newState = merge(rebaseState, fn(rebaseState))
return { rebaseState: newState }
})
}
}
function getInitialRepositoryState(): IRepositoryState {
@ -111,12 +124,17 @@ function getInitialRepositoryState(): IRepositoryState {
workingDirectory: WorkingDirectoryStatus.fromFiles(
new Array<WorkingDirectoryFileChange>()
),
selectedFileIDs: [],
diff: null,
selection: {
kind: ChangesSelectionKind.WorkingDirectory,
selectedFileIDs: [],
diff: null,
},
commitMessage: DefaultCommitMessage,
coAuthors: [],
showCoAuthoredBy: false,
conflictState: null,
stashEntry: null,
currentBranchProtected: false,
},
selectedSection: RepositorySectionTab.Changes,
branchesState: {
@ -145,6 +163,12 @@ function getInitialRepositoryState(): IRepositoryState {
defaultBranch: null,
inferredComparisonBranch: { branch: null, aheadBehind: null },
},
rebaseState: {
step: null,
progress: null,
commits: null,
userHasResolvedConflicts: false,
},
commitAuthor: null,
gitHubUsers: new Map<string, IGitHubUser>(),
commitLookup: new Map<string, Commit>(),
@ -157,7 +181,5 @@ function getInitialRepositoryState(): IRepositoryState {
checkoutProgress: null,
pushPullFetchProgress: null,
revertProgress: null,
branchFilterText: '',
pullRequestFilterText: '',
}
}

View file

@ -28,7 +28,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 version does not support GitHub Desktop. Talk to your server's administrator about upgrading to the latest version of GitHub Enterprise.`
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.`
/**
* An enumeration of the possible steps that the sign in
@ -79,8 +79,8 @@ export interface ISignInState {
/**
* State interface representing the endpoint entry step.
* This is the initial step in the Enterprise sign in flow
* and is not present when signing in to GitHub.com
* This is the initial step in the Enterprise Server sign in
* flow and is not present when signing in to GitHub.com
*/
export interface IEndpointEntryState extends ISignInState {
readonly kind: SignInStep.EndpointEntry
@ -91,7 +91,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 instance.
* signing in to a GitHub Enterprise Server instance.
*/
export interface IAuthenticationState extends ISignInState {
readonly kind: SignInStep.Authentication
@ -100,15 +100,16 @@ 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 instance.
* URL when signing in against a GitHub Enterprise Server
* instance.
*/
readonly endpoint: string
/**
* A value indicating whether or not the endpoint supports
* basic authentication (i.e. username and password). All
* GitHub Enterprise instances support OAuth (or web flow
* sign-in).
* GitHub Enterprise Server instances support OAuth (or web
* flow sign-in).
*/
readonly supportsBasicAuth: boolean
@ -122,8 +123,8 @@ 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 when the user has enabled two factor
* authentication on the host.
* and GitHub Enterprise Server when the user has enabled two
* factor authentication on the host.
*/
export interface ITwoFactorAuthenticationState extends ISignInState {
readonly kind: SignInStep.TwoFactorAuthentication
@ -132,7 +133,8 @@ 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 instance.
* URL when signing in against a GitHub Enterprise Server
* instance.
*/
readonly endpoint: string
@ -187,7 +189,7 @@ interface IAuthenticationEvent {
/**
* A store encapsulating all logic related to signing in a user
* to GitHub.com, or a GitHub Enterprise instance.
* to GitHub.com, or a GitHub Enterprise Server instance.
*/
export class SignInStore extends TypedBaseStore<SignInState | null> {
private state: SignInState | null = null
@ -240,7 +242,7 @@ export class SignInStore extends TypedBaseStore<SignInState | null> {
}
} else {
throw new Error(
`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.`
`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.`
)
}
}
@ -438,9 +440,9 @@ export class SignInStore extends TypedBaseStore<SignInState | null> {
}
/**
* 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.
* Initiate a sign in flow for a GitHub Enterprise Server instance.
* This will put the store in the EndpointEntry step ready to
* receive the url to the enterprise instance.
*/
public beginEnterpriseSignIn() {
this.setState({
@ -482,11 +484,11 @@ export class SignInStore extends TypedBaseStore<SignInState | null> {
let error = e
if (e.name === InvalidURLErrorName) {
error = new Error(
`The GitHub Enterprise instance address doesn't appear to be a valid URL. We're expecting something like https://github.example.com.`
`The GitHub Enterprise Server 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 instances.'
'Unsupported protocol. Only http or https is supported when authenticating with GitHub Enterprise Server instances.'
)
}

View file

@ -10,11 +10,14 @@ import {
isMergeConflictState,
isRebaseConflictState,
RebaseConflictState,
ChangesSelection,
ChangesSelectionKind,
} from '../../app-state'
import { DiffSelectionType, IDiff } from '../../../models/diff'
import { DiffSelectionType } from '../../../models/diff'
import { caseInsensitiveCompare } from '../../compare'
import { IStatsStore } from '../../stats/stats-store'
import { ManualConflictResolution } from '../../../models/manual-conflict-resolution'
import { assertNever } from '../../fatal-error'
/**
* Internal shape of the return value from this response because the compiler
@ -23,8 +26,7 @@ import { ManualConflictResolution } from '../../../models/manual-conflict-resolu
*/
type ChangedFilesResult = {
readonly workingDirectory: WorkingDirectoryStatus
readonly selectedFileIDs: string[]
readonly diff: IDiff | null
readonly selection: ChangesSelection
}
export function updateChangedFiles(
@ -62,19 +64,6 @@ export function updateChangedFiles(
// lookups using .find on the mergedFiles array.
const mergedFileIds = new Set(mergedFiles.map(x => x.id))
// The previously selected files might not be available in the working
// directory any more due to having been committed or discarded so we'll
// do a pass over and filter out any selected files that aren't available.
let selectedFileIDs = state.selectedFileIDs.filter(id =>
mergedFileIds.has(id)
)
// Select the first file if we don't have anything selected and we
// have something to select.
if (selectedFileIDs.length === 0 && mergedFiles.length > 0) {
selectedFileIDs = [mergedFiles[0].id]
}
// The file selection could have changed if the previously selected files
// are no longer selectable (they were discarded or committed) but if they
// were not changed we can reuse the diff. Note, however that we only render
@ -83,17 +72,46 @@ export function updateChangedFiles(
// diff we had, if not we'll clear it.
const workingDirectory = WorkingDirectoryStatus.fromFiles(mergedFiles)
const diff =
selectedFileIDs.length === 1 &&
state.selectedFileIDs.length === 1 &&
state.selectedFileIDs[0] === selectedFileIDs[0]
? state.diff
: null
const selectionKind = state.selection.kind
if (state.selection.kind === ChangesSelectionKind.WorkingDirectory) {
// The previously selected files might not be available in the working
// directory any more due to having been committed or discarded so we'll
// do a pass over and filter out any selected files that aren't available.
let selectedFileIDs = state.selection.selectedFileIDs.filter(id =>
mergedFileIds.has(id)
)
return {
workingDirectory,
selectedFileIDs,
diff,
// Select the first file if we don't have anything selected and we
// have something to select.
if (selectedFileIDs.length === 0 && mergedFiles.length > 0) {
selectedFileIDs = [mergedFiles[0].id]
}
const diff =
selectedFileIDs.length === 1 &&
state.selection.selectedFileIDs.length === 1 &&
state.selection.selectedFileIDs[0] === selectedFileIDs[0]
? state.selection.diff
: null
return {
workingDirectory,
selection: {
kind: ChangesSelectionKind.WorkingDirectory,
selectedFileIDs,
diff,
},
}
} else if (state.selection.kind === ChangesSelectionKind.Stash) {
return {
workingDirectory,
selection: state.selection,
}
} else {
return assertNever(
state.selection,
`Unknown selection kind ${selectionKind}`
)
}
}
@ -117,7 +135,7 @@ function getConflictState(
}
}
if (status.rebaseContext !== null) {
if (status.rebaseInternalState !== null) {
const { currentTip } = status
if (currentTip == null) {
return null
@ -127,7 +145,7 @@ function getConflictState(
targetBranch,
originalBranchTip,
baseBranchTip,
} = status.rebaseContext
} = status.rebaseInternalState
return {
kind: 'rebase',
@ -215,12 +233,11 @@ function performEffectsForRebaseStateChange(
) {
const previousTip = prevConflictState.originalBranchTip
if (
const previousTipChanged =
previousTip !== currentTip &&
currentBranch === prevConflictState.targetBranch
) {
statsStore.recordRebaseSuccessAfterConflicts()
} else {
if (!previousTipChanged) {
statsStore.recordRebaseAbortedAfterConflicts()
}
}
@ -277,3 +294,45 @@ export function updateConflictState(
return newConflictState
}
/**
* Generate the partial state needed to update ChangesState selection property
* when a user or external constraints require us to do so.
*
* @param state The current changes state
* @param files An array of files to select when showing the working directory.
* If undefined this method will preserve the previously selected
* files or pick the first changed file if no selection exists.
*/
export function selectWorkingDirectoryFiles(
state: IChangesState,
files?: ReadonlyArray<WorkingDirectoryFileChange>
): Pick<IChangesState, 'selection'> {
let selectedFileIDs: Array<string>
if (files === undefined) {
if (state.selection.kind === ChangesSelectionKind.WorkingDirectory) {
// No files provided, just a desire to make sure selection is
// working directory. If it already is there's nothing for us to do.
return { selection: state.selection }
} else if (state.workingDirectory.files.length > 0) {
// No files provided and the current selection is stash, pick the
// first file we've got.
selectedFileIDs = [state.workingDirectory.files[0].id]
} else {
// Not much to do here. No files provided, nothing in the
// working directory.
selectedFileIDs = new Array<string>()
}
} else {
selectedFileIDs = files.map(x => x.id)
}
return {
selection: {
kind: ChangesSelectionKind.WorkingDirectory as ChangesSelectionKind.WorkingDirectory,
selectedFileIDs,
diff: null,
},
}
}

View file

@ -4,11 +4,11 @@ import { getDotComAPIEndpoint } from './api'
/**
* Best-effort attempt to figure out if this commit was committed using
* the web flow on GitHub.com or GitHub Enterprise. Web flow
* the web flow on GitHub.com or GitHub Enterprise Server. Web flow
* commits (such as PR merges) will have a special GitHub committer
* with a noreply email address.
*
* For GitHub.com we can be spot on but for GitHub Enterprise it's
* For GitHub.com we can be spot on but for GitHub Enterprise Server it's
* possible we could fail if they've set up a custom smtp host
* that doesn't correspond to the hostname.
*/

View file

@ -45,7 +45,12 @@ export function registerWindowStateChangedEvents(
window.on('unmaximize', () => sendWindowStateEvent(window, 'normal'))
window.on('restore', () => sendWindowStateEvent(window, 'normal'))
window.on('hide', () => sendWindowStateEvent(window, 'hidden'))
window.on('show', () => sendWindowStateEvent(window, 'normal'))
window.on('show', () => {
// because the app can be maximized before being closed - which will restore it
// maximized on the next launch - this function should inspect the current state
// rather than always assume it is a 'normal' launch
sendWindowStateEvent(window, getWindowState(window))
})
}
/**

View file

@ -8,8 +8,7 @@ import { ILaunchStats } from '../lib/stats'
import { menuFromElectronMenu } from '../models/app-menu'
import { now } from './now'
import * as path from 'path'
let windowStateKeeper: any | null = null
import * as windowStateKeeper from 'electron-window-state'
export class AppWindow {
private window: Electron.BrowserWindow
@ -22,13 +21,6 @@ export class AppWindow {
private minHeight = 660
public constructor() {
if (!windowStateKeeper) {
// `electron-window-state` requires Electron's `screen` module, which can
// only be required after the app has emitted `ready`. So require it
// lazily.
windowStateKeeper = require('electron-window-state')
}
const savedWindowState = windowStateKeeper({
defaultWidth: this.minWidth,
defaultHeight: this.minHeight,
@ -51,6 +43,7 @@ export class AppWindow {
disableBlinkFeatures: 'Auxclick',
// Enable, among other things, the ResizeObserver
experimentalFeatures: true,
nodeIntegration: true,
},
acceptFirstMouse: true,
}

View file

@ -43,6 +43,7 @@ export class CrashWindow {
// process but our components which relies on ResizeObserver should
// be able to degrade gracefully.
experimentalFeatures: false,
nodeIntegration: true,
},
}

View file

@ -3,13 +3,10 @@ import '../lib/logging/main/install'
import { app, Menu, ipcMain, BrowserWindow, shell } from 'electron'
import * as Fs from 'fs'
import { MenuLabelsEvent } from '../models/menu-labels'
import { AppWindow } from './app-window'
import {
buildDefaultMenu,
MenuEvent,
MenuLabels,
getAllMenuItems,
} from './menu'
import { buildDefaultMenu, MenuEvent, getAllMenuItems } from './menu'
import { shellNeedsPatching, updateEnvironmentForProcess } from '../lib/shell'
import { parseAppURL } from '../lib/parse-app-url'
import { handleSquirrelEvent } from './squirrel-updater'
@ -61,10 +58,23 @@ function handleUncaughtException(error: Error) {
showUncaughtException(isLaunchError, error)
}
/**
* Calculates the number of seconds the app has been running
*/
function getUptimeInSeconds() {
return (now() - launchTime) / 1000
}
function getExtraErrorContext(): Record<string, string> {
return {
uptime: getUptimeInSeconds().toFixed(3),
time: new Date().toString(),
}
}
process.on('uncaughtException', (error: Error) => {
error = withSourceMappedStack(error)
reportError(error)
reportError(error, getExtraErrorContext())
handleUncaughtException(error)
})
@ -236,11 +246,18 @@ app.on('ready', () => {
createWindow()
Menu.setApplicationMenu(buildDefaultMenu({}))
Menu.setApplicationMenu(
buildDefaultMenu({
selectedShell: null,
selectedExternalEditor: null,
askForConfirmationOnRepositoryRemoval: false,
askForConfirmationOnForcePush: false,
})
)
ipcMain.on(
'update-preferred-app-menu-item-labels',
(event: Electron.IpcMessageEvent, labels: MenuLabels) => {
(event: Electron.IpcMessageEvent, labels: MenuLabelsEvent) => {
// The current application menu is mutable and we frequently
// change whether particular items are enabled or not through
// the update-menu-state IPC event. This menu that we're creating
@ -443,7 +460,10 @@ app.on('ready', () => {
event: Electron.IpcMessageEvent,
{ error, extra }: { error: Error; extra: { [key: string]: string } }
) => {
reportError(error, extra)
reportError(error, {
...getExtraErrorContext(),
...extra,
})
}
)

View file

@ -7,20 +7,22 @@ import { ensureDir } from 'fs-extra'
import { log } from '../log'
import { openDirectorySafe } from '../shell'
import { enableRebaseDialog } from '../../lib/feature-flag'
import { enableRebaseDialog, enableStashing } from '../../lib/feature-flag'
import { MenuLabelsEvent } from '../../models/menu-labels'
import { DefaultEditorLabel } from '../../ui/lib/context-menu'
const defaultEditorLabel = __DARWIN__
? 'Open in External Editor'
: 'Open in external editor'
const defaultShellLabel = __DARWIN__
? 'Open in Terminal'
: 'Open in Command Prompt'
const defaultPullRequestLabel = __DARWIN__
const createPullRequestLabel = __DARWIN__
? 'Create Pull Request'
: 'Create &pull request'
const defaultBranchNameDefaultValue = __DARWIN__
? 'Default Branch'
: 'default branch'
const showPullRequestLabel = __DARWIN__
? 'Show Pull Request'
: 'Show &pull request'
const defaultBranchNameValue = __DARWIN__ ? 'Default Branch' : 'default branch'
const confirmRepositoryRemovalLabel = __DARWIN__ ? 'Remove…' : '&Remove…'
const repositoryRemovalLabel = __DARWIN__ ? 'Remove' : '&Remove'
enum ZoomDirection {
Reset,
@ -28,21 +30,34 @@ enum ZoomDirection {
Out,
}
export type MenuLabels = {
editorLabel?: string
shellLabel?: string
pullRequestLabel?: string
defaultBranchName?: string
}
export function buildDefaultMenu({
editorLabel = defaultEditorLabel,
shellLabel = defaultShellLabel,
pullRequestLabel = defaultPullRequestLabel,
defaultBranchName = defaultBranchNameDefaultValue,
}: MenuLabels): Electron.Menu {
selectedExternalEditor,
selectedShell,
askForConfirmationOnForcePush,
askForConfirmationOnRepositoryRemoval,
hasCurrentPullRequest = false,
defaultBranchName = defaultBranchNameValue,
isForcePushForCurrentRepository = false,
isStashedChangesVisible = false,
}: MenuLabelsEvent): Electron.Menu {
defaultBranchName = truncateWithEllipsis(defaultBranchName, 25)
const removeRepoLabel = askForConfirmationOnRepositoryRemoval
? confirmRepositoryRemovalLabel
: repositoryRemovalLabel
const pullRequestLabel = hasCurrentPullRequest
? showPullRequestLabel
: createPullRequestLabel
const shellLabel =
selectedShell === null ? defaultShellLabel : `Open in ${selectedShell}`
const editorLabel =
selectedExternalEditor === null
? DefaultEditorLabel
: `Open in ${selectedExternalEditor}`
const template = new Array<Electron.MenuItemConstructorOptions>()
const separator: Electron.MenuItemConstructorOptions = { type: 'separator' }
@ -120,7 +135,11 @@ export function buildDefaultMenu({
click: emit('show-preferences'),
},
separator,
{ role: 'quit' }
{
role: 'quit',
label: 'E&xit',
accelerator: 'Alt+F4',
}
)
}
@ -140,6 +159,13 @@ export function buildDefaultMenu({
accelerator: 'CmdOrCtrl+A',
click: emit('select-all'),
},
separator,
{
id: 'find',
label: __DARWIN__ ? 'Find' : '&Find',
accelerator: 'CmdOrCtrl+F',
click: emit('find-text'),
},
],
})
@ -177,6 +203,15 @@ export function buildDefaultMenu({
accelerator: 'CmdOrCtrl+G',
click: emit('go-to-commit-message'),
},
{
label: getStashedChangesLabel(isStashedChangesVisible),
id: 'toggle-stashed-changes',
accelerator: 'Ctrl+H',
click: isStashedChangesVisible
? emit('hide-stashed-changes')
: emit('show-stashed-changes'),
visible: enableStashing(),
},
{
label: __DARWIN__ ? 'Toggle Full Screen' : 'Toggle &full screen',
role: 'togglefullscreen',
@ -230,15 +265,22 @@ export function buildDefaultMenu({
],
})
const pushLabel = getPushLabel(
isForcePushForCurrentRepository,
askForConfirmationOnForcePush
)
const pushEventType = isForcePushForCurrentRepository ? 'force-push' : 'push'
template.push({
label: __DARWIN__ ? 'Repository' : '&Repository',
id: 'repository',
submenu: [
{
id: 'push',
label: __DARWIN__ ? 'Push' : 'P&ush',
label: pushLabel,
accelerator: 'CmdOrCtrl+P',
click: emit('push'),
click: emit(pushEventType),
},
{
id: 'pull',
@ -247,9 +289,9 @@ export function buildDefaultMenu({
click: emit('pull'),
},
{
label: __DARWIN__ ? 'Remove' : '&Remove',
label: removeRepoLabel,
id: 'remove-repository',
accelerator: 'CmdOrCtrl+Delete',
accelerator: 'CmdOrCtrl+Backspace',
click: emit('remove-repository'),
},
separator,
@ -313,6 +355,13 @@ export function buildDefaultMenu({
click: emit('delete-branch'),
},
separator,
{
label: __DARWIN__ ? 'Discard All Changes…' : 'Discard all changes…',
id: 'discard-all-changes',
accelerator: 'CmdOrCtrl+Shift+Backspace',
click: emit('discard-all-changes'),
},
separator,
{
label: __DARWIN__
? `Update from ${defaultBranchName}`
@ -396,6 +445,15 @@ export function buildDefaultMenu({
},
}
const showKeyboardShortcuts: Electron.MenuItemConstructorOptions = {
label: __DARWIN__ ? 'Show Keyboard Shortcuts' : 'Show keyboard shortcuts',
click() {
shell.openExternal(
'https://help.github.com/en/desktop/getting-started-with-github-desktop/keyboard-shortcuts-in-github-desktop'
)
},
}
const showLogsLabel = __DARWIN__
? 'Show Logs in Finder'
: __WIN32__
@ -420,6 +478,7 @@ export function buildDefaultMenu({
submitIssueItem,
contactSupportItem,
showUserGuides,
showKeyboardShortcuts,
showLogsItem,
]
@ -444,6 +503,10 @@ export function buildDefaultMenu({
click: emit('show-release-notes-popup'),
},
],
},
{
label: 'Prune branches',
click: emit('test-prune-branches'),
}
)
}
@ -473,6 +536,29 @@ export function buildDefaultMenu({
return Menu.buildFromTemplate(template)
}
function getPushLabel(
isForcePushForCurrentRepository: boolean,
askForConfirmationOnForcePush: boolean
): string {
if (!isForcePushForCurrentRepository) {
return __DARWIN__ ? 'Push' : 'P&ush'
}
if (askForConfirmationOnForcePush) {
return __DARWIN__ ? 'Force Push…' : 'Force P&ush…'
}
return __DARWIN__ ? 'Force Push' : 'Force P&ush'
}
function getStashedChangesLabel(isStashedChangesVisible: boolean): string {
if (isStashedChangesVisible) {
return __DARWIN__ ? 'Hide Stashed Changes' : 'H&ide stashed changes'
}
return __DARWIN__ ? 'Show Stashed Changes' : 'Sho&w stashed changes'
}
type ClickHandler = (
menuItem: Electron.MenuItem,
browserWindow: Electron.BrowserWindow,
@ -525,29 +611,27 @@ function zoom(direction: ZoomDirection): ClickHandler {
webContents.setZoomFactor(1)
webContents.send('zoom-factor-changed', 1)
} else {
webContents.getZoomFactor(rawZoom => {
const zoomFactors =
direction === ZoomDirection.In ? ZoomInFactors : ZoomOutFactors
const rawZoom = webContents.getZoomFactor()
const zoomFactors =
direction === ZoomDirection.In ? ZoomInFactors : ZoomOutFactors
// So the values that we get from getZoomFactor are floating point
// precision numbers from chromium that don't always round nicely so
// we'll have to do a little trick to figure out which of our supported
// zoom factors the value is referring to.
const currentZoom = findClosestValue(zoomFactors, rawZoom)
// So the values that we get from getZoomFactor are floating point
// precision numbers from chromium that don't always round nicely so
// we'll have to do a little trick to figure out which of our supported
// zoom factors the value is referring to.
const currentZoom = findClosestValue(zoomFactors, rawZoom)
const nextZoomLevel = zoomFactors.find(f =>
direction === ZoomDirection.In ? f > currentZoom : f < currentZoom
)
const nextZoomLevel = zoomFactors.find(f =>
direction === ZoomDirection.In ? f > currentZoom : f < currentZoom
)
// If we couldn't find a zoom level (likely due to manual manipulation
// of the zoom factor in devtools) we'll just snap to the closest valid
// factor we've got.
const newZoom =
nextZoomLevel === undefined ? currentZoom : nextZoomLevel
// If we couldn't find a zoom level (likely due to manual manipulation
// of the zoom factor in devtools) we'll just snap to the closest valid
// factor we've got.
const newZoom = nextZoomLevel === undefined ? currentZoom : nextZoomLevel
webContents.setZoomFactor(newZoom)
webContents.send('zoom-factor-changed', newZoom)
})
webContents.setZoomFactor(newZoom)
webContents.send('zoom-factor-changed', newZoom)
}
}
}

View file

@ -1,6 +1,5 @@
export * from './build-default-menu'
export * from './ensure-item-ids'
export * from './menu-event'
export * from './menu-ids'
export * from './crash-menu'
export * from './get-all-menu-items'

View file

@ -1,5 +1,6 @@
export type MenuEvent =
| 'push'
| 'force-push'
| 'pull'
| 'show-changes'
| 'show-history'
@ -10,6 +11,7 @@ export type MenuEvent =
| 'create-repository'
| 'rename-branch'
| 'delete-branch'
| 'discard-all-changes'
| 'show-preferences'
| 'choose-repository'
| 'open-working-directory'
@ -30,3 +32,7 @@ export type MenuEvent =
| 'open-external-editor'
| 'select-all'
| 'show-release-notes-popup'
| 'show-stashed-changes'
| 'hide-stashed-changes'
| 'test-prune-branches'
| 'find-text'

View file

@ -1,7 +1,7 @@
import { getDotComAPIEndpoint, IAPIEmail } from '../lib/api'
/**
* A GitHub account, representing the user found on GitHub The Website or GitHub Enterprise.
* A GitHub account, representing the user found on GitHub The Website or GitHub Enterprise Server.
*
* This contains a token that will be used for operations that require authentication.
*/
@ -15,7 +15,7 @@ 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 instance
* @param endpoint The server for this account - GitHub or a GitHub Enterprise Server 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

View file

@ -162,6 +162,24 @@ function getAccessKey(text: string): string | null {
return m ? m[1] : null
}
/** Workaround for missing type information on Electron.MenuItem.type */
function parseMenuItem(
type: string
): 'normal' | 'separator' | 'submenu' | 'checkbox' | 'radio' {
switch (type) {
case 'normal':
case 'separator':
case 'submenu':
case 'checkbox':
case 'radio':
return type
default:
throw new Error(
`Unable to parse string ${type} to a valid menu item type`
)
}
}
/**
* Creates an instance of one of the types in the MenuItem type union based
* on an Electron MenuItem instance. Will recurse through all sub menus and
@ -182,8 +200,10 @@ function menuItemFromElectronMenuItem(menuItem: Electron.MenuItem): MenuItem {
const accelerator = getAccelerator(menuItem)
const accessKey = getAccessKey(menuItem.label)
const type = parseMenuItem(menuItem.type)
// normal, separator, submenu, checkbox or radio.
switch (menuItem.type) {
switch (type) {
case 'normal':
return {
id,
@ -230,10 +250,7 @@ function menuItemFromElectronMenuItem(menuItem: Electron.MenuItem): MenuItem {
accessKey,
}
default:
return assertNever(
menuItem.type,
`Unknown menu item type ${menuItem.type}`
)
return assertNever(type, `Unknown menu item type ${type}`)
}
}
/**

View file

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

View file

@ -1,6 +1,8 @@
/** A list of menu ids associated with the main menu in GitHub Desktop */
export type MenuIDs =
| 'rename-branch'
| 'delete-branch'
| 'discard-all-changes'
| 'preferences'
| 'update-branch'
| 'merge-branch'
@ -28,3 +30,4 @@ export type MenuIDs =
| 'about'
| 'create-pull-request'
| 'compare-to-branch'
| 'toggle-stashed-changes'

View file

@ -0,0 +1,52 @@
import { Shell } from '../lib/shells'
import { ExternalEditor } from '../lib/editors'
export type MenuLabelsEvent = {
/**
* Specify the user's selected shell to display in the menu.
*
* Specify `null` to indicate that it is not known currently, which will
* default to a placeholder based on the current platform.
*/
readonly selectedShell: Shell | null
/**
* Specify the user's selected editor to display in the menu.
*
* 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
/**
* Has the use enabled "Show confirmation dialog before force pushing"?
*/
readonly askForConfirmationOnForcePush: boolean
/**
* Has the use enabled "Show confirmation dialog before removing repositories"?
*/
readonly askForConfirmationOnRepositoryRemoval: boolean
/**
* Specify the default branch associated with the current repository.
*
* Omit this value to indicate that the default branch is unknown.
*/
readonly defaultBranchName?: string
/**
* Is the current branch in a state where it can be force pushed to the remote?
*/
readonly isForcePushForCurrentRepository?: boolean
/**
* Specify whether a pull request is associated with the current branch.
*/
readonly hasCurrentPullRequest?: boolean
/**
* Specify whether a stashed change is accessible in the current branch.
*/
readonly isStashedChangesVisible?: boolean
}

View file

@ -7,7 +7,7 @@ import { RetryAction } from './retry-actions'
import { WorkingDirectoryFileChange } from './status'
import { PreferencesTab } from './preferences'
import { ICommitContext } from './commit'
import { RebaseFlowState } from './rebase-flow-state'
import { IStashEntry } from './stash-entry'
export enum PopupType {
RenameBranch = 1,
@ -44,9 +44,11 @@ export enum PopupType {
UsageReportingChanges,
CommitConflictsWarning,
PushNeedsPull,
LocalChangesOverwritten,
RebaseFlow,
ConfirmForcePush,
StashAndSwitchBranch,
ConfirmOverwriteStash,
ConfirmDiscardStash,
}
export type Popup =
@ -80,6 +82,13 @@ export type Popup =
| {
type: PopupType.CreateBranch
repository: Repository
/**
* A flag to indicate the user clicked the "switch branch" link when they
* saw the prompt about the current branch being protected.
*/
handleProtectedBranchWarning?: boolean
initialName?: string
}
| { type: PopupType.SignIn }
@ -162,13 +171,6 @@ export type Popup =
type: PopupType.PushNeedsPull
repository: Repository
}
| {
type: PopupType.LocalChangesOverwritten
/** repository user is checking out in */
repository: Repository
retryAction: RetryAction
overwrittenFiles: ReadonlyArray<string>
}
| {
type: PopupType.ConfirmForcePush
repository: Repository
@ -177,5 +179,19 @@ export type Popup =
| {
type: PopupType.RebaseFlow
repository: Repository
initialState: RebaseFlowState
}
| {
type: PopupType.StashAndSwitchBranch
repository: Repository
branchToCheckout: Branch
}
| {
type: PopupType.ConfirmOverwriteStash
repository: Repository
branchToCheckout: Branch
}
| {
type: PopupType.ConfirmDiscardStash
repository: Repository
stash: IStashEntry
}

View file

@ -103,7 +103,7 @@ export interface IRevertProgress extends IProgress {
export interface IRebaseProgress extends IProgress {
readonly kind: 'rebase'
/** The summary of the commit applied to the base branch */
readonly commitSummary: string
readonly currentCommitSummary: string
/** The number of commits currently rebased onto the base branch */
readonly rebasedCommitCount: number
/** The toal number of commits to rebase on top of the current branch */

View file

@ -11,7 +11,7 @@ export class PullRequestRef {
public constructor(
public readonly ref: string,
public readonly sha: string,
public readonly gitHubRepository: GitHubRepository | null
public readonly gitHubRepository: GitHubRepository
) {}
}
@ -24,7 +24,6 @@ export interface ICommitStatus {
export class PullRequest {
/**
* @param id The database ID.
* @param created The date on which the PR was created.
* @param status The status of the PR. This will be `null` if we haven't looked up its
* status yet or if an error occurred while looking it up.
@ -35,7 +34,6 @@ export class PullRequest {
* @param author The author's login.
*/
public constructor(
public readonly id: number,
public readonly created: Date,
public readonly title: string,
public readonly pullRequestNumber: number,

View file

@ -1,8 +1,11 @@
import { Branch } from './branch'
import { RebaseConflictState } from '../lib/app-state'
import { CommitOneLine } from './commit'
/** Union type representing the possible states of the rebase flow */
export type RebaseFlowState =
export type RebaseFlowStep =
| ChooseBranchesStep
| WarnForcePushStep
| ShowProgressStep
| ShowConflictsStep
| HideConflictsStep
@ -19,6 +22,15 @@ export const enum RebaseStep {
* conflicts.
*/
ChooseBranch = 'ChooseBranch',
/**
* The initial state of a rebase - the user choosing the start point.
*
* This is not encountered if the user tries to 'pull with rebase' and
* encounters conflicts, because the rebase happens as part of the pull
* operation and the only remaining work for the user is to resolve any
* conflicts.
*/
WarnForcePush = 'WarnForcePush',
/**
* After the user chooses which branch to use as the base branch for the
* rebase, the progress view is shown indicating how the rebase work is
@ -69,6 +81,13 @@ export type ChooseBranchesStep = {
readonly initialBranch?: Branch
}
export type WarnForcePushStep = {
readonly kind: RebaseStep.WarnForcePush
readonly baseBranch: Branch
readonly targetBranch: Branch
readonly commits: ReadonlyArray<CommitOneLine>
}
/** Shape of data to show progress of the current rebase */
export type ShowProgressStep = {
readonly kind: RebaseStep.ShowProgress
@ -80,14 +99,13 @@ export type ShowProgressStep = {
* want to defer the rebase action until after _something_ is shown to the
* user.
*/
readonly rebaseAction?: () => Promise<void>
readonly rebaseAction: (() => Promise<void>) | null
}
/** Shape of data to show conflicts that need to be resolved by the user */
export type ShowConflictsStep = {
readonly kind: RebaseStep.ShowConflicts
readonly targetBranch: string
readonly baseBranch?: string
readonly conflictState: RebaseConflictState
}
/** Shape of data to track when user hides conflicts dialog */
@ -98,8 +116,7 @@ export type HideConflictsStep = {
/** Shape of data to use when confirming user should abort rebase */
export type ConfirmAbortStep = {
readonly kind: RebaseStep.ConfirmAbort
readonly targetBranch: string
readonly baseBranch?: string
readonly conflictState: RebaseConflictState
}
/** Shape of data to track when rebase has completed successfully */

View file

@ -2,9 +2,22 @@ import { IRebaseProgress } from './progress'
import { ComputedAction } from './computed-action'
import { CommitOneLine } from './commit'
export type RebaseContext = {
/**
* Rebase internal state used to track how and where the rebase is applied to
* the repository.
*/
export type RebaseInternalState = {
/** The branch containing commits that should be rebased */
readonly targetBranch: string
/**
* The commit ID of the base branch, to be used as a starting point for
* the rebase.
*/
readonly baseBranchTip: string
/**
* The commit ID of the target branch at the start of the rebase, which points
* to the original commit history.
*/
readonly originalBranchTip: string
}
@ -43,13 +56,22 @@ export type RebasePreview =
| RebaseNotSupported
| RebaseLoading
export type RebaseProgressSummary = {
/** A numeric value between 0 and 1 representing the rebase progress */
/** Represents the progress of a Git rebase operation to be shown to the user */
export type GitRebaseProgress = {
/** A numeric value between 0 and 1 representing the percent completed */
readonly value: number
/** Track the current number of commits rebased across dialogs and states */
/** The current number of commits rebased as part of this operation */
readonly rebasedCommitCount: number
/** The commit summary associated with the current commit (if known) */
readonly commitSummary?: string
/** The list of known commits that will be rebased onto the base branch */
readonly commits: ReadonlyArray<CommitOneLine>
/** The commit summary associated with the current commit (if found) */
readonly currentCommitSummary: string | null
/** The count of known commits that will be rebased onto the base branch */
readonly totalCommitCount: number
}
/** Represents a snapshot of the rebase state from the Git repository */
export type GitRebaseSnapshot = {
/** The sequence of commits that are used in the rebase */
readonly commits: ReadonlyArray<CommitOneLine>
/** The progress of the operation */
readonly progress: GitRebaseProgress
}

View file

@ -14,3 +14,19 @@ export interface IRemote {
readonly name: string
readonly url: string
}
/**
* Gets a value indicating whether two remotes can be considered
* structurally equivalent to each other.
*/
export function remoteEquals(x: IRemote | null, y: IRemote | null) {
if (x === y) {
return true
}
if (x === null || y === null) {
return false
}
return x.name === y.name && x.url === y.url
}

View file

@ -15,23 +15,38 @@ function getBaseName(path: string): string {
return baseName
}
/** Base type for a directory you can run git commands successfully */
export type WorkingTree = {
readonly path: string
}
/** A local repository. */
export class Repository {
public readonly name: string
/**
* The main working tree (what we commonly
* think of as the repository's working directory)
*/
private readonly mainWorkTree: WorkingTree
/**
* @param path The working directory of this repository
* @param missing Was the repository missing on disk last we checked?
*/
public constructor(
public readonly path: string,
path: string,
public readonly id: number,
public readonly gitHubRepository: GitHubRepository | null,
public readonly missing: boolean
) {
this.mainWorkTree = { path }
this.name = (gitHubRepository && gitHubRepository.name) || getBaseName(path)
}
public get path(): string {
return this.mainWorkTree.path
}
/**
* A hash of the properties of the object.
*
@ -44,6 +59,12 @@ export class Repository {
}
}
/** A worktree linked to a main working tree (aka `Repository`) */
export type LinkedWorkTree = WorkingTree & {
/** The sha of the head commit in this work tree */
readonly head: string
}
/**
* A snapshot for the local state for a given repository
*/

View file

@ -0,0 +1,41 @@
import { CommittedFileChange } from './status'
export interface IStashEntry {
/** The name of the entry i.e., `stash@{0}` */
readonly name: string
/** The name of the branch at the time the entry was created. */
readonly branchName: string
/** The SHA of the commit object created as a result of stashing. */
readonly stashSha: string
/** The list of files this stash touches */
readonly files: StashedFileChanges
}
/** Whether file changes for a stash entry are loaded or not */
export enum StashedChangesLoadStates {
NotLoaded = 'NotLoaded',
Loading = 'Loading',
Loaded = 'Loaded',
}
/**
* The status of stashed file changes
*
* When the status us `Loaded` all the files associated
* with the stash are made available.
*/
export type StashedFileChanges =
| {
readonly kind:
| StashedChangesLoadStates.NotLoaded
| StashedChangesLoadStates.Loading
}
| {
readonly kind: StashedChangesLoadStates.Loaded
readonly files: ReadonlyArray<CommittedFileChange>
}
export type StashCallback = (stashEntry: IStashEntry) => Promise<void>

View file

@ -70,7 +70,7 @@ export type ManualConflict = {
/** Union of potential conflict scenarios the application should handle */
export type ConflictedFileStatus = ConflictsWithMarkers | ManualConflict
/** Custom typeguard to differentiate ConflictsWithMarkers from other Conflict types */
/** Custom typeguard to differentiate Conflict files from other types */
export function isConflictedFileStatus(
appFileStatus: AppFileStatus
): appFileStatus is ConflictedFileStatus {

View file

@ -1,4 +1,5 @@
import { Branch } from './branch'
import { assertNever } from '../lib/fatal-error'
export enum TipState {
Unknown = 'Unknown',
@ -44,3 +45,36 @@ export type Tip =
| IUnbornRepository
| IDetachedHead
| IValidBranch
/**
* Gets a value indicating whether two Tip instances refer to the
* same canonical Git state.
*/
export function tipEquals(x: Tip, y: Tip) {
if (x === y) {
return true
}
const kind = x.kind
switch (x.kind) {
case TipState.Unknown:
return x.kind === y.kind
case TipState.Unborn:
return x.kind === y.kind && x.ref === y.ref
case TipState.Detached:
return x.kind === y.kind && x.currentSha === y.currentSha
case TipState.Valid:
return x.kind === y.kind && branchEquals(x.branch, y.branch)
default:
return assertNever(x, `Unknown tip state ${kind}`)
}
}
function branchEquals(x: Branch, y: Branch) {
return (
x.type === y.type &&
x.tip.sha === y.tip.sha &&
x.remote === y.remote &&
x.upstream === y.upstream
)
}

View file

@ -0,0 +1,22 @@
import { IStashEntry } from './stash-entry'
export enum UncommittedChangesStrategyKind {
AskForConfirmation = 'AskForConfirmation',
StashOnCurrentBranch = 'StashOnCurrentBranch',
MoveToNewBranch = 'MoveToNewBranch',
}
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,
}

View file

@ -174,10 +174,11 @@ export class AddExistingRepository extends React.Component<
}
private showFilePicker = async () => {
const directory: string[] | null = remote.dialog.showOpenDialog({
const window = remote.getCurrentWindow()
const directory = remote.dialog.showOpenDialog(window, {
properties: ['createDirectory', 'openDirectory'],
})
if (!directory) {
if (directory === undefined) {
return
}

View file

@ -120,6 +120,8 @@ export class CreateRepository extends React.Component<
}
public async componentDidMount() {
window.addEventListener('focus', this.onWindowFocus)
const gitIgnoreNames = await getGitIgnoreNames()
this.setState({ gitIgnoreNames })
@ -129,9 +131,7 @@ export class CreateRepository extends React.Component<
const isRepository = await isGitRepository(this.state.path)
this.setState({ isRepository })
await this.updateReadMeExists(this.state.path, this.state.name)
window.addEventListener('focus', this.onWindowFocus)
this.updateReadMeExists(this.state.path, this.state.name)
}
public componentWillUnmount() {
@ -139,18 +139,21 @@ export class CreateRepository extends React.Component<
}
private onPathChanged = async (path: string) => {
const isRepository = await isGitRepository(path)
await this.updateReadMeExists(path, this.state.name)
this.setState({ path, isValidPath: null })
this.setState({ isRepository, path, isValidPath: null })
const isRepository = await isGitRepository(path)
// Only update isRepository if the path is still the
// same one we were using to check whether it looked
// like a repository.
this.setState(state => (state.path === path ? { isRepository } : null))
this.updateReadMeExists(path, this.state.name)
}
private onNameChanged = async (name: string) => {
if (enableReadmeOverwriteWarning()) {
await this.updateReadMeExists(this.state.path, name)
}
private onNameChanged = (name: string) => {
this.setState({ name })
this.updateReadMeExists(this.state.path, name)
}
private onDescriptionChanged = (description: string) => {
@ -158,11 +161,12 @@ export class CreateRepository extends React.Component<
}
private showFilePicker = async () => {
const directory: string[] | null = remote.dialog.showOpenDialog({
const window = remote.getCurrentWindow()
const directory = remote.dialog.showOpenDialog(window, {
properties: ['createDirectory', 'openDirectory'],
})
if (!directory) {
if (directory === undefined) {
return
}
@ -173,9 +177,15 @@ export class CreateRepository extends React.Component<
}
private async updateReadMeExists(path: string, name: string) {
if (!enableReadmeOverwriteWarning()) {
return
}
const fullPath = Path.join(path, sanitizedRepositoryName(name), 'README.md')
const readMeExists = await FSE.pathExists(fullPath)
this.setState({ readMeExists })
// Only update readMeExists if the path is still the same
this.setState(state => (state.path === path ? { readMeExists } : null))
}
private resolveRepositoryRoot = async (): Promise<string> => {
@ -232,7 +242,11 @@ export class CreateRepository extends React.Component<
if (this.state.createWithReadme) {
try {
await writeDefaultReadme(fullPath, this.state.name)
await writeDefaultReadme(
fullPath,
this.state.name,
this.state.description
)
} catch (e) {
log.error(`createRepository: unable to write README at ${fullPath}`, e)
this.props.dispatcher.postError(e)
@ -580,11 +594,9 @@ export class CreateRepository extends React.Component<
)
}
private onWindowFocus = async () => {
private onWindowFocus = () => {
// Verify whether or not a README.md file exists at the chosen directory
// in case one has been added or removed and the warning can be displayed.
if (enableReadmeOverwriteWarning()) {
await this.updateReadMeExists(this.state.path, this.state.name)
}
this.updateReadMeExists(this.state.path, this.state.name)
}
}

View file

@ -3,8 +3,10 @@ import * as Path from 'path'
const DefaultReadmeName = 'README.md'
function defaultReadmeContents(name: string): string {
return `# ${name}\n`
function defaultReadmeContents(name: string, description?: string): string {
return description !== undefined
? `# ${name}\n ${description}\n`
: `# ${name}\n`
}
/**
@ -12,9 +14,10 @@ function defaultReadmeContents(name: string): string {
*/
export async function writeDefaultReadme(
path: string,
name: string
name: string,
description?: string
): Promise<void> {
const fullPath = Path.join(path, DefaultReadmeName)
const contents = defaultReadmeContents(name)
const contents = defaultReadmeContents(name, description)
await writeFile(fullPath, contents)
}

View file

@ -93,13 +93,16 @@ import { PopupType, Popup } from '../models/popup'
import { OversizedFiles } from './changes/oversized-files-warning'
import { UsageStatsChange } from './usage-stats-change'
import { PushNeedsPullWarning } from './push-needs-pull'
import { LocalChangesOverwrittenWarning } from './local-changes-overwritten'
import { RebaseFlow, ConfirmForcePush } from './rebase'
import {
initializeNewRebaseFlow,
initializeRebaseFlowForConflictedRepository,
isCurrentBranchForcePush,
} from '../lib/rebase'
import { BannerType } from '../models/banner'
import { StashAndSwitchBranch } from './stash-changes/stash-and-switch-branch-dialog'
import { OverwriteStash } from './stash-changes/overwrite-stashed-changes-dialog'
import { ConfirmDiscardStashDialog } from './stashing/confirm-discard-stash'
const MinuteInMilliseconds = 1000 * 60
const HourInMilliseconds = MinuteInMilliseconds * 60
@ -187,10 +190,10 @@ export class App extends React.Component<IAppProps, IAppState> {
const initialTimeout = window.setTimeout(async () => {
window.clearTimeout(initialTimeout)
await this.props.appStore.refreshAllIndicators()
await this.props.appStore.refreshAllSidebarIndicators()
this.updateIntervalHandle = window.setInterval(() => {
this.props.appStore.refreshAllIndicators()
this.props.appStore.refreshAllSidebarIndicators()
}, UpdateRepositoryIndicatorInterval)
}, InitialRepositoryIndicatorTimeout)
})
@ -291,6 +294,8 @@ export class App extends React.Component<IAppProps, IAppState> {
switch (name) {
case 'push':
return this.push()
case 'force-push':
return this.push({ forceWithLease: true })
case 'pull':
return this.pull()
case 'show-changes':
@ -313,25 +318,23 @@ export class App extends React.Component<IAppProps, IAppState> {
return this.renameBranch()
case 'delete-branch':
return this.deleteBranch()
case 'discard-all-changes':
return this.discardAllChanges()
case 'show-preferences':
return this.props.dispatcher.showPopup({ type: PopupType.Preferences })
case 'open-working-directory':
return this.openCurrentRepositoryWorkingDirectory()
case 'update-branch': {
case 'update-branch':
this.props.dispatcher.recordMenuInitiatedUpdate()
return this.updateBranch()
}
case 'compare-to-branch': {
case 'compare-to-branch':
return this.showHistory(true)
}
case 'merge-branch': {
case 'merge-branch':
this.props.dispatcher.recordMenuInitiatedMerge()
return this.mergeBranch()
}
case 'rebase-branch': {
case 'rebase-branch':
this.props.dispatcher.recordMenuInitiatedRebase()
return this.showRebaseDialog()
}
case 'show-repository-settings':
return this.showRepositorySettings()
case 'view-repository-on-github':
@ -348,9 +351,8 @@ export class App extends React.Component<IAppProps, IAppState> {
return this.boomtown()
case 'go-to-commit-message':
return this.goToCommitMessage()
case 'open-pull-request': {
case 'open-pull-request':
return this.openPullRequest()
}
case 'install-cli':
return this.props.dispatcher.installCLI()
case 'open-external-editor':
@ -359,6 +361,14 @@ export class App extends React.Component<IAppProps, IAppState> {
return this.selectAll()
case 'show-release-notes-popup':
return this.showFakeReleaseNotesPopup()
case 'show-stashed-changes':
return this.showStashedChanges()
case 'hide-stashed-changes':
return this.hideStashedChanges()
case 'test-prune-branches':
return this.testPruneBranches()
case 'find-text':
return this.findText()
}
return assertNever(name, `Unknown menu event name: ${name}`)
@ -419,6 +429,14 @@ export class App extends React.Component<IAppProps, IAppState> {
}
}
private testPruneBranches() {
if (!__DEV__) {
return
}
this.props.appStore._testPruneBranches()
}
/**
* Handler for the 'select-all' menu event, dispatches
* a custom DOM event originating from the element which
@ -440,6 +458,28 @@ export class App extends React.Component<IAppProps, IAppState> {
}
}
/**
* Handler for the 'find-text' menu event, dispatches
* a custom DOM event originating from the element which
* currently has keyboard focus (or the document if no element
* has focus). Components have a chance to intercept this
* event and implement their own 'find-text' logic. One
* example of this custom event is the text diff which
* will trigger a search dialog when seeing this event.
*/
private findText() {
const event = new CustomEvent('find-text', {
bubbles: true,
cancelable: true,
})
if (document.activeElement != null) {
document.activeElement.dispatchEvent(event)
} else {
document.dispatchEvent(event)
}
}
private boomtown() {
setImmediate(() => {
throw new Error('Boomtown!')
@ -467,18 +507,14 @@ export class App extends React.Component<IAppProps, IAppState> {
}
private getDotComAccount(): Account | null {
const state = this.props.appStore.getState()
const accounts = state.accounts
const dotComAccount = accounts.find(
const dotComAccount = this.state.accounts.find(
a => a.endpoint === getDotComAPIEndpoint()
)
return dotComAccount || null
}
private getEnterpriseAccount(): Account | null {
const state = this.props.appStore.getState()
const accounts = state.accounts
const enterpriseAccount = accounts.find(
const enterpriseAccount = this.state.accounts.find(
a => a.endpoint !== getDotComAPIEndpoint()
)
return enterpriseAccount || null
@ -599,6 +635,24 @@ export class App extends React.Component<IAppProps, IAppState> {
}
}
private discardAllChanges() {
const state = this.state.selectedState
if (state == null || state.type !== SelectionType.Repository) {
return
}
const { workingDirectory } = state.state.changesState
this.props.dispatcher.showPopup({
type: PopupType.ConfirmDiscardChanges,
repository: state.repository,
files: workingDirectory.files,
showDiscardChangesSetting: false,
discardingAllChanges: true,
})
}
private showAddLocalRepo = () => {
return this.props.dispatcher.showPopup({ type: PopupType.AddRepository })
}
@ -694,13 +748,17 @@ export class App extends React.Component<IAppProps, IAppState> {
return this.props.dispatcher.showFoldout({ type: FoldoutType.Branch })
}
private push() {
private push(options?: { forceWithLease: boolean }) {
const state = this.state.selectedState
if (state == null || state.type !== SelectionType.Repository) {
return
}
this.props.dispatcher.push(state.repository)
if (options && options.forceWithLease) {
this.props.dispatcher.confirmOrForcePush(state.repository)
} else {
this.props.dispatcher.push(state.repository)
}
}
private async pull() {
@ -712,6 +770,24 @@ export class App extends React.Component<IAppProps, IAppState> {
this.props.dispatcher.pull(state.repository)
}
private showStashedChanges() {
const state = this.state.selectedState
if (state == null || state.type !== SelectionType.Repository) {
return
}
this.props.dispatcher.selectStashedFile(state.repository)
}
private hideStashedChanges() {
const state = this.state.selectedState
if (state == null || state.type !== SelectionType.Repository) {
return
}
this.props.dispatcher.hideStashedChanges(state.repository)
}
public componentDidMount() {
document.ondragover = e => {
if (e.dataTransfer != null) {
@ -764,7 +840,12 @@ export class App extends React.Component<IAppProps, IAppState> {
}
if (shouldRenderApplicationMenu()) {
if (event.key === 'Alt') {
if (event.key === 'Shift' && event.altKey) {
this.props.dispatcher.setAccessKeyHighlightState(false)
} else if (event.key === 'Alt') {
if (event.shiftKey) {
return
}
// Immediately close the menu if open and the user hits Alt. This is
// a Windows convention.
if (
@ -945,12 +1026,13 @@ export class App extends React.Component<IAppProps, IAppState> {
const repositoryState = this.props.repositoryStateManager.get(repository)
const initialState = initializeNewRebaseFlow(repositoryState)
const initialStep = initializeNewRebaseFlow(repositoryState)
this.props.dispatcher.setRebaseFlowStep(repository, initialStep)
this.props.dispatcher.showPopup({
type: PopupType.RebaseFlow,
repository,
initialState,
})
}
@ -1127,12 +1209,18 @@ export class App extends React.Component<IAppProps, IAppState> {
switch (popup.type) {
case PopupType.RenameBranch:
const stash =
this.state.selectedState !== null &&
this.state.selectedState.type === SelectionType.Repository
? this.state.selectedState.state.changesState.stashEntry
: null
return (
<RenameBranch
key="rename-branch"
dispatcher={this.props.dispatcher}
repository={popup.repository}
branch={popup.branch}
stash={stash}
/>
)
case PopupType.DeleteBranch:
@ -1187,7 +1275,7 @@ export class App extends React.Component<IAppProps, IAppState> {
}
confirmForcePush={this.state.askForConfirmationOnForcePush}
selectedExternalEditor={this.state.selectedExternalEditor}
optOutOfUsageTracking={this.props.appStore.getStatsOptOut()}
optOutOfUsageTracking={this.state.optOutOfUsageTracking}
enterpriseAccount={this.getEnterpriseAccount()}
onDismissed={this.onPopupDismissed}
selectedShell={this.state.selectedShell}
@ -1299,6 +1387,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onDismissed={this.onPopupDismissed}
dispatcher={this.props.dispatcher}
initialName={popup.initialName || ''}
handleProtectedBranchWarning={popup.handleProtectedBranchWarning}
/>
)
}
@ -1356,16 +1445,23 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.RemoveRepository:
return (
<ConfirmRemoveRepository
key="confirm-remove-repository"
repository={popup.repository}
onConfirmation={this.onConfirmRepoRemoval}
onDismissed={this.onPopupDismissed}
/>
)
case PopupType.TermsAndConditions:
return <TermsAndConditions onDismissed={this.onPopupDismissed} />
return (
<TermsAndConditions
key="terms-and-conditions"
onDismissed={this.onPopupDismissed}
/>
)
case PopupType.PushBranchCommits:
return (
<PushBranchCommits
key="push-branch-commits"
dispatcher={this.props.dispatcher}
repository={popup.repository}
branch={popup.branch}
@ -1375,10 +1471,16 @@ export class App extends React.Component<IAppProps, IAppState> {
/>
)
case PopupType.CLIInstalled:
return <CLIInstalled onDismissed={this.onPopupDismissed} />
return (
<CLIInstalled
key="cli-installed"
onDismissed={this.onPopupDismissed}
/>
)
case PopupType.GenericGitAuthentication:
return (
<GenericGitAuthentication
key="generic-git-authentication"
hostname={popup.hostname}
onDismiss={this.onPopupDismissed}
onSave={this.onSaveCredentials}
@ -1411,6 +1513,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.InitializeLFS:
return (
<InitializeLFS
key="initialize-lfs"
repositories={popup.repositories}
onDismissed={this.onPopupDismissed}
onInitialize={this.initializeLFS}
@ -1419,6 +1522,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.LFSAttributeMismatch:
return (
<AttributeMismatch
key="lsf-attribute-mismatch"
onDismissed={this.onPopupDismissed}
onUpdateExistingFilters={this.updateExistingLFSFilters}
/>
@ -1426,6 +1530,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.UpstreamAlreadyExists:
return (
<UpstreamAlreadyExists
key="upstream-already-exists"
repository={popup.repository}
existingRemote={popup.existingRemote}
onDismissed={this.onPopupDismissed}
@ -1436,6 +1541,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.ReleaseNotes:
return (
<ReleaseNotes
key="release-notes"
emoji={this.state.emoji}
newRelease={popup.newRelease}
onDismissed={this.onPopupDismissed}
@ -1444,6 +1550,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.DeletePullRequest:
return (
<DeletePullRequest
key="delete-pull-request"
dispatcher={this.props.dispatcher}
repository={popup.repository}
branch={popup.branch}
@ -1471,6 +1578,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<MergeConflictsDialog
key="merge-conflicts-dialog"
dispatcher={this.props.dispatcher}
repository={popup.repository}
workingDirectory={workingDirectory}
@ -1487,6 +1595,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.OversizedFiles:
return (
<OversizedFiles
key="oversized-files"
oversizedFiles={popup.oversizedFiles}
onDismissed={this.onPopupDismissed}
dispatcher={this.props.dispatcher}
@ -1513,6 +1622,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<AbortMergeWarning
key="abort-merge-warning"
dispatcher={this.props.dispatcher}
repository={popup.repository}
onDismissed={this.onPopupDismissed}
@ -1524,6 +1634,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.UsageReportingChanges:
return (
<UsageStatsChange
key="usage-stats-change"
onOpenUsageDataUrl={this.openUsageDataUrl}
onDismissed={this.onUsageReportingDismissed}
/>
@ -1531,6 +1642,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.CommitConflictsWarning:
return (
<CommitConflictsWarning
key="commit-conflicts-warning"
dispatcher={this.props.dispatcher}
files={popup.files}
repository={popup.repository}
@ -1541,34 +1653,14 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.PushNeedsPull:
return (
<PushNeedsPullWarning
key="push-needs-pull"
dispatcher={this.props.dispatcher}
repository={popup.repository}
onDismissed={this.onPopupDismissed}
/>
)
case PopupType.LocalChangesOverwritten: {
const { selectedState } = this.state
if (
selectedState === null ||
selectedState.type !== SelectionType.Repository
) {
return null
}
const { workingDirectory } = selectedState.state.changesState
return (
<LocalChangesOverwrittenWarning
dispatcher={this.props.dispatcher}
repository={popup.repository}
retryAction={popup.retryAction}
overwrittenFiles={popup.overwrittenFiles}
workingDirectory={workingDirectory}
onDismissed={this.onPopupDismissed}
/>
)
}
case PopupType.RebaseFlow: {
const { selectedState } = this.state
const { selectedState, emoji } = this.state
if (
selectedState === null ||
@ -1577,10 +1669,9 @@ export class App extends React.Component<IAppProps, IAppState> {
return null
}
const { initialState } = popup
const { changesState } = selectedState.state
const { changesState, rebaseState } = selectedState.state
const { workingDirectory, conflictState } = changesState
const { progress, step, userHasResolvedConflicts } = rebaseState
if (conflictState !== null && conflictState.kind === 'merge') {
log.warn(
@ -1589,18 +1680,31 @@ export class App extends React.Component<IAppProps, IAppState> {
return null
}
if (step === null) {
log.warn(
'[App] invalid state encountered - rebase flow should not be active when step is null'
)
return null
}
return (
<RebaseFlow
key="rebase-flow"
repository={popup.repository}
openFileInExternalEditor={this.openFileInExternalEditor}
dispatcher={this.props.dispatcher}
onFlowEnded={this.onRebaseFlowEnded}
initialState={initialState}
workingDirectory={workingDirectory}
conflictState={conflictState}
progress={progress}
step={step}
userHasResolvedConflicts={userHasResolvedConflicts}
askForConfirmationOnForcePush={
this.state.askForConfirmationOnForcePush
}
resolvedExternalEditor={this.state.resolvedExternalEditor}
openRepositoryInShell={this.openCurrentRepositoryInShell}
onShowRebaseConflictsBanner={this.onShowRebaseConflictsBanner}
emoji={emoji}
/>
)
}
@ -1609,6 +1713,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<ConfirmForcePush
key="confirm-force-push"
dispatcher={this.props.dispatcher}
repository={popup.repository}
upstreamBranch={popup.upstreamBranch}
@ -1617,6 +1722,58 @@ export class App extends React.Component<IAppProps, IAppState> {
/>
)
}
case PopupType.StashAndSwitchBranch: {
const { repository, branchToCheckout } = popup
const {
branchesState,
changesState,
} = this.props.repositoryStateManager.get(repository)
const { tip } = branchesState
if (tip.kind !== TipState.Valid) {
return null
}
const currentBranch = tip.branch
const hasAssociatedStash = changesState.stashEntry !== null
return (
<StashAndSwitchBranch
key="stash-and-switch-branch"
dispatcher={this.props.dispatcher}
repository={popup.repository}
currentBranch={currentBranch}
branchToCheckout={branchToCheckout}
hasAssociatedStash={hasAssociatedStash}
onDismissed={this.onPopupDismissed}
/>
)
}
case PopupType.ConfirmOverwriteStash: {
const { repository, branchToCheckout: branchToCheckout } = popup
return (
<OverwriteStash
key="overwite-stash"
dispatcher={this.props.dispatcher}
repository={repository}
branchToCheckout={branchToCheckout}
onDismissed={this.onPopupDismissed}
/>
)
}
case PopupType.ConfirmDiscardStash: {
const { repository, stash } = popup
return (
<ConfirmDiscardStashDialog
key="confirm-discard-stash-dialog"
dispatcher={this.props.dispatcher}
repository={repository}
stash={stash}
onDismissed={this.onPopupDismissed}
/>
)
}
default:
return assertNever(popup, `Unknown popup type: ${popup}`)
}
@ -1629,7 +1786,7 @@ export class App extends React.Component<IAppProps, IAppState> {
this.props.dispatcher.setBanner({
type: BannerType.RebaseConflictsFound,
targetBranch,
onOpenDialog: () => {
onOpenDialog: async () => {
const { changesState } = this.props.repositoryStateManager.get(
repository
)
@ -1641,21 +1798,26 @@ export class App extends React.Component<IAppProps, IAppState> {
)
return
}
const initialState = initializeRebaseFlowForConflictedRepository(
await this.props.dispatcher.setRebaseProgressFromState(repository)
const initialStep = initializeRebaseFlowForConflictedRepository(
conflictState
)
this.props.dispatcher.setRebaseFlowStep(repository, initialStep)
this.props.dispatcher.showPopup({
type: PopupType.RebaseFlow,
repository,
initialState,
})
},
})
}
private onRebaseFlowEnded = () => {
private onRebaseFlowEnded = (repository: Repository) => {
this.props.dispatcher.closePopup()
this.props.dispatcher.endRebaseFlow(repository)
}
private onUsageReportingDismissed = (optOut: boolean) => {
@ -1924,18 +2086,14 @@ export class App extends React.Component<IAppProps, IAppState> {
const rebaseInProgress =
conflictState !== null && conflictState.kind === 'rebase'
const { pullWithRebase, tip, rebasedBranches } = state.branchesState
const { aheadBehind, branchesState } = state
const { pullWithRebase, tip } = branchesState
if (tip.kind === TipState.Valid && tip.branch.remote !== null) {
remoteName = tip.branch.remote
}
let branchWasRebased = false
if (tip.kind === TipState.Valid) {
const localBranchName = tip.branch.nameWithoutRemote
const { sha } = tip.branch.tip
const foundEntry = rebasedBranches.get(localBranchName)
branchWasRebased = foundEntry === sha
}
const isForcePush = isCurrentBranchForcePush(branchesState, aheadBehind)
return (
<PushPullButton
@ -1949,7 +2107,7 @@ export class App extends React.Component<IAppProps, IAppState> {
tipState={tip.kind}
pullWithRebase={pullWithRebase}
rebaseInProgress={rebaseInProgress}
branchWasRebased={branchWasRebased}
isForcePush={isForcePush}
/>
)
}
@ -2019,8 +2177,14 @@ export class App extends React.Component<IAppProps, IAppState> {
}
const currentFoldout = this.state.currentFoldout
const isOpen =
!!currentFoldout && currentFoldout.type === FoldoutType.Branch
let isOpen = false
let handleProtectedBranchWarning: boolean | undefined
if (currentFoldout !== null && currentFoldout.type === FoldoutType.Branch) {
isOpen = true
handleProtectedBranchWarning = currentFoldout.handleProtectedBranchWarning
}
const repository = selection.repository
const branchesState = selection.state.branchesState
@ -2036,6 +2200,7 @@ export class App extends React.Component<IAppProps, IAppState> {
pullRequests={branchesState.openPullRequests}
currentPullRequest={branchesState.currentPullRequest}
isLoadingPullRequests={branchesState.isLoadingPullRequests}
handleProtectedBranchWarning={handleProtectedBranchWarning}
/>
)
}
@ -2141,6 +2306,7 @@ export class App extends React.Component<IAppProps, IAppState> {
emoji={state.emoji}
sidebarWidth={state.sidebarWidth}
commitSummaryWidth={state.commitSummaryWidth}
stashedFilesWidth={state.stashedFilesWidth}
issuesStore={this.props.issuesStore}
gitHubUserStore={this.props.gitHubUserStore}
onViewCommitOnGitHub={this.onViewCommitOnGitHub}
@ -2178,7 +2344,8 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<Welcome
dispatcher={this.props.dispatcher}
appStore={this.props.appStore}
optOut={this.state.optOutOfUsageTracking}
accounts={this.state.accounts}
signInState={this.state.signInState}
/>
)

View file

@ -18,7 +18,7 @@ export class RebaseConflictsBanner extends React.Component<
IRebaseConflictsBannerProps,
{}
> {
private openDialog = () => {
private openDialog = async () => {
this.props.onDismissed()
this.props.onOpenDialog()
this.props.dispatcher.recordRebaseConflictsDialogReopened()

View file

@ -12,6 +12,7 @@ import { TabBar } from '../tab-bar'
import { CloneableRepositoryFilterList } from '../clone-repository/cloneable-repository-filter-list'
import { IAPIRepository } from '../../lib/api'
import { assertNever } from '../../lib/fatal-error'
import { ClickSource } from '../lib/list'
interface IBlankSlateProps {
/** A function to call when the user chooses to create a repository. */
@ -26,11 +27,11 @@ interface IBlankSlateProps {
/** The logged in account for GitHub.com. */
readonly dotComAccount: Account | null
/** The logged in account for GitHub Enterprise. */
/** The logged in account for GitHub Enterprise Server. */
readonly enterpriseAccount: Account | null
/**
* A map keyed on a user account (GitHub.com or GitHub Enterprise)
* A map keyed on a user account (GitHub.com or GitHub Enterprise Server)
* containing an object with repositories that the authenticated
* user has explicit permission (:read, :write, or :admin) to access
* as well as information about whether the list of repositories
@ -141,15 +142,24 @@ export class BlankSlateView extends React.Component<
this.ensureRepositoriesForAccount(this.getSelectedAccount())
}
public componentDidUpdate(prevProps: IBlankSlateProps) {
this.ensureRepositoriesForAccount(this.getSelectedAccount())
public componentDidUpdate(
prevProps: IBlankSlateProps,
prevState: IBlankSlateState
) {
if (
prevProps.dotComAccount !== this.props.dotComAccount ||
prevProps.enterpriseAccount !== this.props.enterpriseAccount ||
prevState.selectedTab !== this.state.selectedTab
) {
this.ensureRepositoriesForAccount(this.getSelectedAccount())
}
}
private ensureRepositoriesForAccount(account: Account | null) {
if (account !== null) {
const accountState = this.props.apiRepositories.get(account)
if (accountState === undefined || accountState.repositories === null) {
if (accountState === undefined) {
this.props.onRefreshRepositories(account)
}
}
@ -217,12 +227,19 @@ export class BlankSlateView extends React.Component<
repositories={repositories}
onSelectionChanged={this.onSelectionChanged}
onFilterTextChanged={this.onFilterTextChanged}
onItemClicked={this.onItemClicked}
/>
{this.renderCloneSelectedRepositoryButton(selectedItem)}
</>
)
}
private onItemClicked = (repository: IAPIRepository, source: ClickSource) => {
if (source.kind === 'keyboard' && source.event.key === 'Enter') {
this.onCloneSelectedRepository()
}
}
private renderCloneSelectedRepositoryButton(
selectedItem: IAPIRepository | null
) {
@ -291,7 +308,7 @@ export class BlankSlateView extends React.Component<
return (
<TabBar selectedIndex={selectedIndex} onTabClicked={this.onTabClicked}>
<span>GitHub.com</span>
<span>Enterprise</span>
<span>GitHub Enterprise Server</span>
</TabBar>
)
}

View file

@ -19,6 +19,7 @@ import {
BranchGroupIdentifier,
} from './group-branches'
import { NoBranches } from './no-branches'
import { SelectionDirection } from '../lib/list'
const RowHeight = 30
@ -161,9 +162,9 @@ export class BranchList extends React.Component<
this.setState(createState(nextProps))
}
public selectFirstItem(focus: boolean = false) {
public selectNextItem(focus: boolean = false, direction: SelectionDirection) {
if (this.branchFilterList !== null) {
this.branchFilterList.selectFirstItem(focus)
this.branchFilterList.selectNextItem(focus, direction)
}
}

View file

@ -21,6 +21,12 @@ import { PullRequestList } from './pull-request-list'
import { IBranchListItem } from './group-branches'
import { renderDefaultBranch } from './branch-renderer'
import { IMatches } from '../../lib/fuzzy-find'
import { startTimer } from '../lib/timing'
import {
UncommittedChangesStrategyKind,
UncommittedChangesStrategy,
askToStash,
} from '../../models/uncommitted-changes-strategy'
interface IBranchesContainerProps {
readonly dispatcher: Dispatcher
@ -31,14 +37,15 @@ interface IBranchesContainerProps {
readonly currentBranch: Branch | null
readonly recentBranches: ReadonlyArray<Branch>
readonly pullRequests: ReadonlyArray<PullRequest>
readonly branchFilterText: string
readonly pullRequestFilterText: string
/** The pull request associated with the current branch. */
readonly currentPullRequest: PullRequest | null
/** Are we currently loading pull requests? */
readonly isLoadingPullRequests: boolean
/** Was this component launched from the "Protected Branch" warning message? */
readonly handleProtectedBranchWarning?: boolean
}
interface IBranchesContainerState {
@ -59,8 +66,8 @@ export class BranchesContainer extends React.Component<
this.state = {
selectedBranch: props.currentBranch,
selectedPullRequest: props.currentPullRequest,
branchFilterText: props.branchFilterText,
pullRequestFilterText: props.pullRequestFilterText,
branchFilterText: '',
pullRequestFilterText: '',
}
}
@ -95,18 +102,21 @@ export class BranchesContainer extends React.Component<
)
}
private renderOpenPullRequestsBubble() {
const { pullRequests } = this.props
if (pullRequests.length > 0) {
return <span className="count">{pullRequests.length}</span>
}
return null
}
private renderTabBar() {
if (!this.props.repository.gitHubRepository) {
return null
}
let countElement = null
if (this.props.pullRequests) {
countElement = (
<span className="count">{this.props.pullRequests.length}</span>
)
}
return (
<TabBar
onTabClicked={this.onTabClicked}
@ -115,8 +125,7 @@ export class BranchesContainer extends React.Component<
<span>Branches</span>
<span className="pull-request-tab">
{__DARWIN__ ? 'Pull Requests' : 'Pull requests'}
{countElement}
{this.renderOpenPullRequestsBubble()}
</span>
</TabBar>
)
@ -233,10 +242,27 @@ export class BranchesContainer extends React.Component<
private onBranchItemClick = (branch: Branch) => {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
const currentBranch = this.props.currentBranch
const {
currentBranch,
repository,
handleProtectedBranchWarning,
} = this.props
if (currentBranch == null || currentBranch.name !== branch.name) {
this.props.dispatcher.checkoutBranch(this.props.repository, branch)
const timer = startTimer('checkout branch from list', repository)
// if the user arrived at this dialog from the Protected Branch flow
// we should bypass the "Switch Branch" flow and get out of the user's way
const strategy: UncommittedChangesStrategy = handleProtectedBranchWarning
? {
kind: UncommittedChangesStrategyKind.MoveToNewBranch,
transientStashEntry: null,
}
: askToStash
this.props.dispatcher
.checkoutBranch(repository, branch, strategy)
.then(() => timer.done())
}
}
@ -249,10 +275,13 @@ export class BranchesContainer extends React.Component<
}
private onCreateBranchWithName = (name: string) => {
const { repository, handleProtectedBranchWarning } = this.props
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
this.props.dispatcher.showPopup({
type: PopupType.CreateBranch,
repository: this.props.repository,
repository,
handleProtectedBranchWarning,
initialName: name,
})
}
@ -278,22 +307,14 @@ export class BranchesContainer extends React.Component<
private onPullRequestClicked = (pullRequest: PullRequest) => {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
this.props.dispatcher.checkoutPullRequest(
this.props.repository,
pullRequest
const timer = startTimer(
'checkout pull request from list',
this.props.repository
)
this.props.dispatcher
.checkoutPullRequest(this.props.repository, pullRequest)
.then(() => timer.done())
this.onPullRequestSelectionChanged(pullRequest)
}
public componentWillUnmount() {
this.props.dispatcher.setBranchFilterText(
this.props.repository,
this.state.branchFilterText
)
this.props.dispatcher.setPullRequestFilterText(
this.props.repository,
this.state.pullRequestFilterText
)
}
}

View file

@ -31,7 +31,7 @@ interface IBlankSlateActionProps {
* A callback which is invoked when the user clicks
* or activates the action using their keyboard.
*/
readonly onClick: () => void
readonly onClick: (e: React.MouseEvent<HTMLButtonElement>) => void
/**
* The type of action, currently supported actions are

View file

@ -1,15 +1,13 @@
import * as React from 'react'
import { AppFileStatus } from '../../models/status'
import { PathLabel } from '../lib/path-label'
import { Octicon, iconForStatus } from '../octicons'
import { Checkbox, CheckboxValue } from '../lib/checkbox'
import { mapStatus } from '../../lib/status'
import { WorkingDirectoryFileChange } from '../../models/status'
interface IChangedFileProps {
readonly id: string
readonly path: string
readonly status: AppFileStatus
readonly file: WorkingDirectoryFileChange
readonly include: boolean | null
readonly availableWidth: number
readonly disableSelection: boolean
@ -17,9 +15,7 @@ interface IChangedFileProps {
/** Callback called when user right-clicks on an item */
readonly onContextMenu: (
id: string,
path: string,
status: AppFileStatus,
file: WorkingDirectoryFileChange,
event: React.MouseEvent<HTMLDivElement>
) => void
}
@ -28,7 +24,7 @@ interface IChangedFileProps {
export class ChangedFile extends React.Component<IChangedFileProps, {}> {
private handleCheckboxChange = (event: React.FormEvent<HTMLInputElement>) => {
const include = event.currentTarget.checked
this.props.onIncludeChanged(this.props.path, include)
this.props.onIncludeChanged(this.props.file.path, include)
}
private get checkboxValue(): CheckboxValue {
@ -42,7 +38,7 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
}
public render() {
const status = this.props.status
const { status, path } = this.props.file
const fileStatus = mapStatus(status)
const listItemPadding = 10 * 2
@ -70,8 +66,8 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
/>
<PathLabel
path={this.props.path}
status={this.props.status}
path={path}
status={status}
availableWidth={availablePathWidth}
/>
@ -85,11 +81,6 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
}
private onContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
this.props.onContextMenu(
this.props.id,
this.props.path,
this.props.status,
event
)
this.props.onContextMenu(this.props.file, event)
}
}

View file

@ -6,7 +6,6 @@ import { Dispatcher } from '../dispatcher'
import { IMenuItem } from '../../lib/menu-item'
import { revealInFileManager } from '../../lib/app-shell'
import {
AppFileStatus,
WorkingDirectoryStatus,
WorkingDirectoryFileChange,
AppFileStatusKind,
@ -35,12 +34,63 @@ import { basename } from 'path'
import { ICommitContext } from '../../models/commit'
import { RebaseConflictState } from '../../lib/app-state'
import { ContinueRebase } from './continue-rebase'
import { enablePullWithRebase } from '../../lib/feature-flag'
import { enableStashing } from '../../lib/feature-flag'
import { Octicon, OcticonSymbol } from '../octicons'
import { IStashEntry } from '../../models/stash-entry'
import * as classNames from 'classnames'
const RowHeight = 29
const StashIcon = new OcticonSymbol(
16,
16,
'M3.002 15H15V4c.51 0 1 .525 1 .996V15c0 .471-.49 1-1 1H4.002c-.51 ' +
'0-1-.529-1-1zm-2-2H13V2c.51 0 1 .525 1 .996V13c0 .471-.49 1-1 ' +
'1H2.002c-.51 0-1-.529-1-1zm10.14-13A.86.86 0 0 1 12 .857v10.286a.86.86 ' +
'0 0 1-.857.857H.857A.86.86 0 0 1 0 11.143V.857A.86.86 0 0 1 .857 0h10.286zM11 ' +
'11V1H1v10h10zM3 6c0-1.66 1.34-3 3-3s3 1.34 3 3-1.34 3-3 3-3-1.34-3-3z'
)
const GitIgnoreFileName = '.gitignore'
/** Compute the 'Include All' checkbox value from the repository state */
function getIncludeAllValue(
workingDirectory: WorkingDirectoryStatus,
rebaseConflictState: RebaseConflictState | null
) {
if (rebaseConflictState !== null) {
if (workingDirectory.files.length === 0) {
// the current commit will be skipped in the rebase
return CheckboxValue.Off
}
// untracked files will be skipped by the rebase, so we need to ensure that
// the "Include All" checkbox matches this state
const onlyUntrackedFilesFound = workingDirectory.files.every(
f => f.status.kind === AppFileStatusKind.Untracked
)
if (onlyUntrackedFilesFound) {
return CheckboxValue.Off
}
const onlyTrackedFilesFound = workingDirectory.files.every(
f => f.status.kind !== AppFileStatusKind.Untracked
)
// show "Mixed" if we have a mixture of tracked and untracked changes
return onlyTrackedFilesFound ? CheckboxValue.On : CheckboxValue.Mixed
}
const { includeAll } = workingDirectory
if (includeAll === true) {
return CheckboxValue.On
} else if (includeAll === false) {
return CheckboxValue.Off
} else {
return CheckboxValue.Mixed
}
}
interface IChangesListProps {
readonly repository: Repository
readonly workingDirectory: WorkingDirectoryStatus
@ -53,9 +103,9 @@ interface IChangesListProps {
readonly onDiscardChanges: (file: WorkingDirectoryFileChange) => void
readonly askForConfirmationOnDiscardChanges: boolean
readonly focusCommitMessage: boolean
readonly onDiscardAllChanges: (
readonly onDiscardChangesFromFiles: (
files: ReadonlyArray<WorkingDirectoryFileChange>,
isDiscardingAllChanges?: boolean
isDiscardingAllChanges: boolean
) => void
/** Callback that fires on page scroll to pass the new scrollTop location */
@ -75,6 +125,7 @@ interface IChangesListProps {
readonly dispatcher: Dispatcher
readonly availableWidth: number
readonly isCommitting: boolean
readonly currentBranchProtected: boolean
/**
* Click event handler passed directly to the onRowClick prop of List, see
@ -113,6 +164,10 @@ interface IChangesListProps {
* @param fullPath The full path to the file on disk
*/
readonly onOpenInExternalEditor: (fullPath: string) => void
readonly stashEntry: IStashEntry | null
readonly isShowingStashEntry: boolean
}
interface IChangesState {
@ -166,7 +221,15 @@ export class ChangesList extends React.Component<
}
private renderRow = (row: number): JSX.Element => {
const file = this.props.workingDirectory.files[row]
const {
workingDirectory,
rebaseConflictState,
isCommitting,
onIncludeChanged,
availableWidth,
} = this.props
const file = workingDirectory.files[row]
const selection = file.selection.getSelectionType()
const includeAll =
@ -176,34 +239,31 @@ export class ChangesList extends React.Component<
? false
: null
const include =
rebaseConflictState !== null
? file.status.kind !== AppFileStatusKind.Untracked
: includeAll
const disableSelection = isCommitting || rebaseConflictState !== null
return (
<ChangedFile
id={file.id}
path={file.path}
status={file.status}
include={includeAll}
file={file}
include={include}
key={file.id}
onContextMenu={this.onItemContextMenu}
onIncludeChanged={this.props.onIncludeChanged}
availableWidth={this.props.availableWidth}
disableSelection={this.props.isCommitting}
onIncludeChanged={onIncludeChanged}
availableWidth={availableWidth}
disableSelection={disableSelection}
/>
)
}
private get includeAllValue(): CheckboxValue {
const includeAll = this.props.workingDirectory.includeAll
if (includeAll === true) {
return CheckboxValue.On
} else if (includeAll === false) {
return CheckboxValue.Off
} else {
return CheckboxValue.Mixed
}
}
private onDiscardAllChanges = () => {
this.props.onDiscardAllChanges(this.props.workingDirectory.files)
this.props.onDiscardChangesFromFiles(
this.props.workingDirectory.files,
true
)
}
private onDiscardChanges = (files: ReadonlyArray<string>) => {
@ -232,7 +292,10 @@ export class ChangesList extends React.Component<
const discardingAllChanges =
modifiedFiles.length === workingDirectory.files.length
this.props.onDiscardAllChanges(modifiedFiles, discardingAllChanges)
this.props.onDiscardChangesFromFiles(
modifiedFiles,
discardingAllChanges
)
}
}
}
@ -253,6 +316,11 @@ export class ChangesList extends React.Component<
private onContextMenu = (event: React.MouseEvent<any>) => {
event.preventDefault()
// need to preserve the working directory state while dealing with conflicts
if (this.props.rebaseConflictState !== null) {
return
}
const items: IMenuItem[] = [
{
label: __DARWIN__ ? 'Discard All Changes…' : 'Discard all changes…',
@ -264,27 +332,73 @@ export class ChangesList extends React.Component<
showContextualMenu(items)
}
private onItemContextMenu = (
id: string,
path: string,
status: AppFileStatus,
event: React.MouseEvent<HTMLDivElement>
) => {
event.preventDefault()
private getDiscardChangesMenuItem = (
paths: ReadonlyArray<string>
): IMenuItem => {
return {
label: this.getDiscardChangesMenuItemLabel(paths),
action: () => this.onDiscardChanges(paths),
}
}
private getCopyPathMenuItem = (
file: WorkingDirectoryFileChange
): IMenuItem => {
return {
label: CopyFilePathLabel,
action: () => {
const fullPath = Path.join(this.props.repository.path, file.path)
clipboard.writeText(fullPath)
},
}
}
private getRevealInFileManagerMenuItem = (
file: WorkingDirectoryFileChange
): IMenuItem => {
return {
label: RevealInFileManagerLabel,
action: () => revealInFileManager(this.props.repository, file.path),
enabled: file.status.kind !== AppFileStatusKind.Deleted,
}
}
private getOpenInExternalEditorMenuItem = (
file: WorkingDirectoryFileChange,
enabled: boolean
): IMenuItem => {
const { externalEditorLabel, repository } = this.props
const openInExternalEditor = externalEditorLabel
? `Open in ${externalEditorLabel}`
: DefaultEditorLabel
return {
label: openInExternalEditor,
action: () => {
const fullPath = Path.join(repository.path, file.path)
this.props.onOpenInExternalEditor(fullPath)
},
enabled,
}
}
private getDefaultContextMenu(
file: WorkingDirectoryFileChange
): ReadonlyArray<IMenuItem> {
const { id, path, status } = file
const extension = Path.extname(path)
const isSafeExtension = isSafeFileExtension(extension)
const openInExternalEditor = this.props.externalEditorLabel
? `Open in ${this.props.externalEditorLabel}`
: DefaultEditorLabel
const wd = this.props.workingDirectory
const { workingDirectory, selectedFileIDs } = this.props
const selectedFiles = new Array<WorkingDirectoryFileChange>()
const paths = new Array<string>()
const extensions = new Set<string>()
const addItemToArray = (fileID: string) => {
const newFile = wd.findFileWithID(fileID)
const newFile = workingDirectory.findFileWithID(fileID)
if (newFile) {
selectedFiles.push(newFile)
paths.push(newFile.path)
@ -296,10 +410,10 @@ export class ChangesList extends React.Component<
}
}
if (this.props.selectedFileIDs.includes(id)) {
if (selectedFileIDs.includes(id)) {
// user has selected a file inside an existing selection
// -> context menu entries should be applied to all selected files
this.props.selectedFileIDs.forEach(addItemToArray)
selectedFileIDs.forEach(addItemToArray)
} else {
// this is outside their previous selection
// -> context menu entries should be applied to just this file
@ -307,14 +421,7 @@ export class ChangesList extends React.Component<
}
const items: IMenuItem[] = [
{
label: this.getDiscardChangesMenuItemLabel(paths),
action: () => this.onDiscardChanges(paths),
},
{
label: __DARWIN__ ? 'Discard All Changes…' : 'Discard all changes…',
action: () => this.onDiscardAllChanges(),
},
this.getDiscardChangesMenuItem(paths),
{ type: 'separator' },
]
if (paths.length === 1) {
@ -354,35 +461,66 @@ export class ChangesList extends React.Component<
})
})
const enabled = isSafeExtension && status.kind !== AppFileStatusKind.Deleted
items.push(
{ type: 'separator' },
{
label: CopyFilePathLabel,
action: () => {
const fullPath = Path.join(this.props.repository.path, path)
clipboard.writeText(fullPath)
},
},
{
label: RevealInFileManagerLabel,
action: () => revealInFileManager(this.props.repository, path),
enabled: status.kind !== AppFileStatusKind.Deleted,
},
{
label: openInExternalEditor,
action: () => {
const fullPath = Path.join(this.props.repository.path, path)
this.props.onOpenInExternalEditor(fullPath)
},
enabled: isSafeExtension && status.kind !== AppFileStatusKind.Deleted,
},
this.getCopyPathMenuItem(file),
this.getRevealInFileManagerMenuItem(file),
this.getOpenInExternalEditorMenuItem(file, enabled),
{
label: OpenWithDefaultProgramLabel,
action: () => this.props.onOpenItem(path),
enabled: isSafeExtension && status.kind !== AppFileStatusKind.Deleted,
enabled,
}
)
return items
}
private getRebaseContextMenu(
file: WorkingDirectoryFileChange
): ReadonlyArray<IMenuItem> {
const { path, status } = file
const extension = Path.extname(path)
const isSafeExtension = isSafeFileExtension(extension)
const items = new Array<IMenuItem>()
if (file.status.kind === AppFileStatusKind.Untracked) {
items.push(this.getDiscardChangesMenuItem([file.path]), {
type: 'separator',
})
}
const enabled = isSafeExtension && status.kind !== AppFileStatusKind.Deleted
items.push(
this.getCopyPathMenuItem(file),
this.getRevealInFileManagerMenuItem(file),
this.getOpenInExternalEditorMenuItem(file, enabled),
{
label: OpenWithDefaultProgramLabel,
action: () => this.props.onOpenItem(path),
enabled,
}
)
return items
}
private onItemContextMenu = (
file: WorkingDirectoryFileChange,
event: React.MouseEvent<HTMLDivElement>
) => {
event.preventDefault()
const items =
this.props.rebaseConflictState === null
? this.getDefaultContextMenu(file)
: this.getRebaseContextMenu(file)
showContextualMenu(items)
}
@ -417,23 +555,43 @@ export class ChangesList extends React.Component<
}
private renderCommitMessageForm = (): JSX.Element => {
if (this.props.rebaseConflictState !== null && enablePullWithRebase()) {
const {
rebaseConflictState,
workingDirectory,
repository,
dispatcher,
isCommitting,
currentBranchProtected,
} = this.props
if (rebaseConflictState !== null) {
const hasUntrackedChanges = workingDirectory.files.some(
f => f.status.kind === AppFileStatusKind.Untracked
)
return (
<ContinueRebase
dispatcher={this.props.dispatcher}
repository={this.props.repository}
rebaseConflictState={this.props.rebaseConflictState}
workingDirectory={this.props.workingDirectory}
isCommitting={this.props.isCommitting}
dispatcher={dispatcher}
repository={repository}
rebaseConflictState={rebaseConflictState}
workingDirectory={workingDirectory}
isCommitting={isCommitting}
hasUntrackedChanges={hasUntrackedChanges}
/>
)
}
const fileCount = this.props.workingDirectory.files.length
const fileCount = workingDirectory.files.length
const includeAllValue = getIncludeAllValue(
workingDirectory,
rebaseConflictState
)
const anyFilesSelected =
fileCount > 0 && this.includeAllValue !== CheckboxValue.Off
const filesSelected = this.props.workingDirectory.files.filter(
fileCount > 0 && includeAllValue !== CheckboxValue.Off
const filesSelected = workingDirectory.files.filter(
f => f.selection.getSelectionType() !== DiffSelectionType.None
)
const singleFileCommit = filesSelected.length === 1
@ -445,12 +603,12 @@ export class ChangesList extends React.Component<
gitHubUser={this.props.gitHubUser}
commitAuthor={this.props.commitAuthor}
anyFilesSelected={anyFilesSelected}
repository={this.props.repository}
dispatcher={this.props.dispatcher}
repository={repository}
dispatcher={dispatcher}
commitMessage={this.props.commitMessage}
focusCommitMessage={this.props.focusCommitMessage}
autocompletionProviders={this.props.autocompletionProviders}
isCommitting={this.props.isCommitting}
isCommitting={isCommitting}
showCoAuthoredBy={this.props.showCoAuthoredBy}
coAuthors={this.props.coAuthors}
placeholder={this.getPlaceholderMessage(
@ -458,27 +616,77 @@ export class ChangesList extends React.Component<
singleFileCommit
)}
singleFileCommit={singleFileCommit}
key={this.props.repository.id}
key={repository.id}
currentBranchProtected={currentBranchProtected}
/>
)
}
private onStashEntryClicked = () => {
const { isShowingStashEntry, dispatcher, repository } = this.props
if (isShowingStashEntry) {
dispatcher.selectWorkingDirectoryFiles(repository)
// If the button is clicked, that implies the stash was not restored or discarded
dispatcher.recordNoActionTakenOnStash()
} else {
dispatcher.selectStashedFile(repository)
dispatcher.recordStashView()
}
}
private renderStashedChanges() {
if (!enableStashing()) {
return null
}
if (this.props.stashEntry === null) {
return null
}
const className = classNames(
'stashed-changes-button',
this.props.isShowingStashEntry ? 'selected' : null
)
return (
<button
className={className}
onClick={this.onStashEntryClicked}
tabIndex={0}
aria-selected={this.props.isShowingStashEntry}
>
<Octicon className="stack-icon" symbol={StashIcon} />
<div className="text">Stashed Changes</div>
<Octicon symbol={OcticonSymbol.chevronRight} />
</button>
)
}
public render() {
const fileCount = this.props.workingDirectory.files.length
const filesPlural = fileCount === 1 ? 'file' : 'files'
const filesDescription = `${fileCount} changed ${filesPlural}`
const includeAllValue = getIncludeAllValue(
this.props.workingDirectory,
this.props.rebaseConflictState
)
const disableAllCheckbox =
fileCount === 0 ||
this.props.isCommitting ||
this.props.rebaseConflictState !== null
return (
<div className="changes-list-container file-list">
<div className="header" onContextMenu={this.onContextMenu}>
<Checkbox
label={filesDescription}
value={this.includeAllValue}
value={includeAllValue}
onChange={this.onIncludeAllChanged}
disabled={fileCount === 0 || this.props.isCommitting}
disabled={disableAllCheckbox}
/>
</div>
<List
id="changes-list"
rowCount={this.props.workingDirectory.files.length}
@ -492,6 +700,7 @@ export class ChangesList extends React.Component<
onScroll={this.onScroll}
setScrollTop={this.props.changesListScrollTop}
/>
{this.renderStashedChanges()}
{this.renderCommitMessageForm()}
</div>
)

View file

@ -22,6 +22,9 @@ import { Octicon, OcticonSymbol } from '../octicons'
import { IAuthor } from '../../models/author'
import { IMenuItem } from '../../lib/menu-item'
import { ICommitContext } from '../../models/commit'
import { startTimer } from '../lib/timing'
import { ProtectedBranchWarning } from './protected-branch-warning'
import { enableBranchProtectionWarningFlow } from '../../lib/feature-flag'
const addAuthorIcon = new OcticonSymbol(
12,
@ -46,6 +49,7 @@ interface ICommitMessageProps {
readonly isCommitting: boolean
readonly placeholder: string
readonly singleFileCommit: boolean
readonly currentBranchProtected: boolean
/**
* Whether or not to show a field for adding co-authors to
@ -118,7 +122,10 @@ export class CommitMessage extends React.Component<
public componentWillUnmount() {
// We're unmounting, likely due to the user switching to the history tab.
// Let's persist our commit message in the dispatcher.
this.props.dispatcher.setCommitMessage(this.props.repository, this.state)
this.props.dispatcher.setCommitMessage(this.props.repository, {
summary: this.state.summary,
description: this.state.description,
})
}
/**
@ -214,7 +221,9 @@ export class CommitMessage extends React.Component<
trailers,
}
const timer = startTimer('create commit', this.props.repository)
const commitCreated = await this.props.onCreateCommit(commitContext)
timer.done()
if (commitCreated) {
this.clearCommitMessage()
@ -433,6 +442,22 @@ export class CommitMessage extends React.Component<
return <div className={className}>{this.renderCoAuthorToggleButton()}</div>
}
private renderProtectedBranchWarning = (branch: string) => {
if (!enableBranchProtectionWarningFlow()) {
return null
}
const { currentBranchProtected, dispatcher } = this.props
if (!currentBranchProtected) {
return null
}
return (
<ProtectedBranchWarning currentBranch={branch} dispatcher={dispatcher} />
)
}
public render() {
const branchName = this.props.branch ? this.props.branch : 'master'
@ -495,6 +520,8 @@ export class CommitMessage extends React.Component<
{this.renderCoAuthorInput()}
{this.renderProtectedBranchWarning(branchName)}
<Button
type="submit"
className="commit-button"

View file

@ -13,16 +13,17 @@ interface IContinueRebaseProps {
readonly workingDirectory: WorkingDirectoryStatus
readonly rebaseConflictState: RebaseConflictState
readonly isCommitting: boolean
readonly hasUntrackedChanges: boolean
}
export class ContinueRebase extends React.Component<IContinueRebaseProps, {}> {
private onSubmit = async () => {
const { manualResolutions } = this.props.rebaseConflictState
const { rebaseConflictState } = this.props
await this.props.dispatcher.continueRebase(
this.props.repository,
this.props.workingDirectory,
manualResolutions
rebaseConflictState
)
}
@ -46,8 +47,16 @@ export class ContinueRebase extends React.Component<IContinueRebaseProps, {}> {
const loading = this.props.isCommitting ? <Loading /> : undefined
const warnAboutUntrackedFiles = this.props.hasUntrackedChanges ? (
<div className="warning-untracked-files">
Untracked files will be excluded
</div>
) : (
undefined
)
return (
<div id="continue-rebase" role="group">
<div id="continue-rebase">
<Button
type="submit"
className="commit-button"
@ -58,6 +67,8 @@ export class ContinueRebase extends React.Component<IContinueRebaseProps, {}> {
{loading}
<span>{loading !== undefined ? 'Rebasing' : 'Continue rebase'}</span>
</Button>
{warnAboutUntrackedFiles}
</div>
)
}

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import { BlankslateAction } from './blankslate-action'
import { MenuIDs } from '../../main-process/menu'
import { MenuIDs } from '../../models/menu-ids'
import { executeMenuItemById } from '../main-process-proxy'
interface IMenuBackedBlankSlateActionProps {
@ -49,6 +49,16 @@ interface IMenuBackedBlankSlateActionProps {
* clickable.
*/
readonly disabled?: boolean
/**
* A callback which is invoked when the user clicks
* or activates the action using their keyboard.
*
* In order to suppress the menu backed action from being invoked
* consumers of this event will need to suppress the default behavior
* by calling `e.preventDefault`.
*/
readonly onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
}
/**
@ -81,7 +91,13 @@ export class MenuBackedBlankslateAction extends React.Component<
)
}
private onClick = () => {
executeMenuItemById(this.props.menuItemId)
private onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (this.props.onClick !== undefined) {
this.props.onClick(e)
}
if (!e.defaultPrevented) {
executeMenuItemById(this.props.menuItemId)
}
}
}

View file

@ -4,8 +4,11 @@ import * as ReactCSSTransitionReplace from 'react-css-transition-replace'
import { encodePathAsUrl } from '../../lib/path'
import { Repository } from '../../models/repository'
import { LinkButton } from '../lib/link-button'
import { enableNoChangesCreatePRBlankslateAction } from '../../lib/feature-flag'
import { MenuIDs } from '../../main-process/menu'
import {
enableNoChangesCreatePRBlankslateAction,
enableStashing,
} from '../../lib/feature-flag'
import { MenuIDs } from '../../models/menu-ids'
import { IMenu, MenuItem } from '../../models/app-menu'
import memoizeOne from 'memoize-one'
import { getPlatformSpecificNameOrSymbolForModifier } from '../../lib/menu-item'
@ -16,6 +19,9 @@ import { TipState, IValidBranch } from '../../models/tip'
import { Ref } from '../lib/ref'
import { IAheadBehind } from '../../models/branch'
import { IRemote } from '../../models/remote'
import { isCurrentBranchForcePush } from '../../lib/rebase'
import { StashedChangesLoadStates } from '../../models/stash-entry'
import { Dispatcher } from '../dispatcher'
function formatMenuItemLabel(text: string) {
if (__WIN32__ || __LINUX__) {
@ -33,6 +39,8 @@ function formatParentMenuLabel(menuItem: IMenuItemInfo) {
const PaperStackImage = encodePathAsUrl(__dirname, 'static/paper-stack.svg')
interface INoChangesProps {
readonly dispatcher: Dispatcher
/**
* The currently selected repository
*/
@ -209,7 +217,8 @@ export class NoChanges extends React.Component<
private renderMenuBackedAction(
itemId: MenuIDs,
title: string,
description?: string | JSX.Element
description?: string | JSX.Element,
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
) {
const menuItem = this.getMenuItemInfo(itemId)
@ -226,6 +235,7 @@ export class NoChanges extends React.Component<
menuItemId={itemId}
buttonText={formatMenuItemLabel(menuItem.label)}
disabled={!menuItem.enabled}
onClick={onClick}
/>
)
}
@ -235,10 +245,15 @@ export class NoChanges extends React.Component<
return this.renderMenuBackedAction(
'open-working-directory',
`View the files of your repository in ${fileManager}`
`View the files of your repository in ${fileManager}`,
undefined,
this.onShowInFileManagerClicked
)
}
private onShowInFileManagerClicked = () =>
this.props.dispatcher.recordSuggestedStepOpenWorkingDirectory()
private renderViewOnGitHub() {
const isGitHub = this.props.repository.gitHubRepository !== null
@ -248,10 +263,15 @@ export class NoChanges extends React.Component<
return this.renderMenuBackedAction(
'view-repository-on-github',
`Open the repository page on GitHub in your browser`
`Open the repository page on GitHub in your browser`,
undefined,
this.onViewOnGitHubClicked
)
}
private onViewOnGitHubClicked = () =>
this.props.dispatcher.recordSuggestedStepViewOnGitHub()
private openPreferences = () => {
executeMenuItemById('preferences')
}
@ -287,9 +307,17 @@ export class NoChanges extends React.Component<
</>
)
return this.renderMenuBackedAction(itemId, title, description)
return this.renderMenuBackedAction(
itemId,
title,
description,
this.onOpenInExternalEditorClicked
)
}
private onOpenInExternalEditorClicked = () =>
this.props.dispatcher.recordSuggestedStepOpenInExternalEditor()
private renderRemoteAction() {
const { remote, aheadBehind, branchesState } = this.props.repositoryState
const { tip, defaultBranch, currentPullRequest } = branchesState
@ -307,6 +335,14 @@ export class NoChanges extends React.Component<
return this.renderPublishBranchAction(tip)
}
const isForcePush = isCurrentBranchForcePush(branchesState, aheadBehind)
if (isForcePush) {
// do not render an action currently after the rebase has completed, as
// the default behaviour is currently to pull in changes from the tracking
// branch which will could potentially lead to a more confusing history
return null
}
if (aheadBehind.behind > 0) {
return this.renderPullBranchAction(tip, remote, aheadBehind)
}
@ -329,6 +365,65 @@ export class NoChanges extends React.Component<
return null
}
private renderViewStashAction() {
if (!enableStashing()) {
return null
}
const { changesState, branchesState } = this.props.repositoryState
const { tip } = branchesState
if (tip.kind !== TipState.Valid) {
return null
}
const { stashEntry } = changesState
if (stashEntry === null) {
return null
}
if (stashEntry.files.kind !== StashedChangesLoadStates.Loaded) {
return null
}
const numChanges = stashEntry.files.files.length
const description = (
<>
You have {numChanges} {numChanges === 1 ? 'change' : 'changes'} in
progress that you have not yet committed.
</>
)
const discoverabilityContent = (
<>
When a stash exists, access it at the bottom of the Changes tab to the
left.
</>
)
const itemId: MenuIDs = 'toggle-stashed-changes'
const menuItem = this.getMenuItemInfo(itemId)
if (menuItem === undefined) {
log.error(`Could not find matching menu item for ${itemId}`)
return null
}
return (
<MenuBackedBlankslateAction
key="view-stash-action"
title="View your stashed changes"
menuItemId={itemId}
description={description}
discoverabilityContent={discoverabilityContent}
buttonText="View stash"
type="primary"
disabled={menuItem !== null && !menuItem.enabled}
onClick={this.onViewStashClicked}
/>
)
}
private onViewStashClicked = () =>
this.props.dispatcher.recordSuggestedStepViewStash()
private renderPublishRepositoryAction() {
// This is a bit confusing, there's no dedicated
// publish menu item, the 'Push' menu item will initiate
@ -359,10 +454,14 @@ export class NoChanges extends React.Component<
menuItemId={itemId}
type="primary"
disabled={!menuItem.enabled}
onClick={this.onPublishRepositoryClicked}
/>
)
}
private onPublishRepositoryClicked = () =>
this.props.dispatcher.recordSuggestedStepPublishRepository()
private renderPublishBranchAction(tip: IValidBranch) {
// This is a bit confusing, there's no dedicated
// publish branch menu item, the 'Push' menu item will initiate
@ -404,10 +503,14 @@ export class NoChanges extends React.Component<
buttonText="Publish branch"
type="primary"
disabled={!menuItem.enabled}
onClick={this.onPublishBranchClicked}
/>
)
}
private onPublishBranchClicked = () =>
this.props.dispatcher.recordSuggestedStepPublishBranch()
private renderPullBranchAction(
tip: IValidBranch,
remote: IRemote,
@ -479,7 +582,7 @@ export class NoChanges extends React.Component<
<>
You have{' '}
{aheadBehind.ahead === 1 ? 'one local commit' : 'local commits'} waiting
to be pushed to {isGitHub ? 'GitHub' : 'the remote'}
to be pushed to {isGitHub ? 'GitHub' : 'the remote'}.
</>
)
@ -540,10 +643,14 @@ export class NoChanges extends React.Component<
discoverabilityContent={this.renderDiscoverabilityElements(menuItem)}
type="primary"
disabled={!menuItem.enabled}
onClick={this.onCreatePullRequestClicked}
/>
)
}
private onCreatePullRequestClicked = () =>
this.props.dispatcher.recordSuggestedStepCreatePullRequest()
private renderActions() {
return (
<>
@ -558,7 +665,7 @@ export class NoChanges extends React.Component<
transitionEnterTimeout={750}
transitionLeaveTimeout={500}
>
{this.renderRemoteAction()}
{this.renderViewStashAction() || this.renderRemoteAction()}
</ReactCSSTransitionReplace>
<div className="actions">
{this.renderOpenInExternalEditor()}
@ -590,8 +697,8 @@ export class NoChanges extends React.Component<
<div className="text">
<h1>No local changes</h1>
<p>
You have no uncommitted changes in your repository! Here are
some friendly suggestions for what to do next.
There are no uncommitted changes for this repository. Here are
some actions you may find useful:
</p>
</div>
<img src={PaperStackImage} className="blankslate-image" />

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