diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0b10093544d..e22a9bd70ab 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,13 +4,5 @@ updates: directory: / schedule: interval: weekly - - - package-ecosystem: 'npm' - directory: '/' - open-pull-requests-limit: 5 - schedule: - interval: 'weekly' - allow: - - dependency-name: 'electron-builder' - - dependency-name: 'electron-packager' - - dependency-name: 'electron-installer-*' + # Disable version updates and keep only security updates + open-pull-requests-limit: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 620dcf12bab..93b1658df82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,40 @@ on: tags: - 'release-*.*.*-linux*' pull_request: - branches: - - linux - - 'linux-release-*' + workflow_call: + inputs: + repository: + default: desktop/desktop + required: false + type: string + ref: + required: true + type: string + upload-artifacts: + default: false + required: false + type: boolean + environment: + type: string + required: true + sign: + type: boolean + default: true + required: false + secrets: + AZURE_CODE_SIGNING_TENANT_ID: + AZURE_CODE_SIGNING_CLIENT_ID: + AZURE_CODE_SIGNING_CLIENT_SECRET: + DESKTOP_OAUTH_CLIENT_ID: + DESKTOP_OAUTH_CLIENT_SECRET: + APPLE_ID: + APPLE_ID_PASSWORD: + APPLE_TEAM_ID: + APPLE_APPLICATION_CERT: + APPLE_APPLICATION_CERT_PASSWORD: + +env: + NODE_VERSION: 20.11.1 jobs: build: @@ -45,13 +76,6 @@ jobs: with: node-version: ${{ matrix.node }} cache: yarn - - # This step can be removed as soon as official Windows arm64 builds are published: - # https://github.com/nodejs/build/issues/2450#issuecomment-705853342 - - name: Get NodeJS node-gyp lib for Windows arm64 - if: ${{ matrix.os == 'windows-2019' && matrix.arch == 'arm64' }} - run: .\script\download-nodejs-win-arm64.ps1 ${{ matrix.node }} - - name: Install and build dependencies run: yarn env: diff --git a/.node-version b/.node-version index 3876fd49864..2dbbe00e679 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -18.16.1 +20.11.1 diff --git a/.nvmrc b/.nvmrc index 5e0828ad15c..ee09fac75c8 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18.16.1 +v20.11.1 diff --git a/.tool-versions b/.tool-versions index 35dde7c8767..a6f2871079c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ python 3.9.5 -nodejs 18.16.1 +nodejs 20.11.1 diff --git a/app/package.json b/app/package.json index bbce8d431a3..ac71ee0e550 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "productName": "GitHub Desktop", "bundleID": "com.github.GitHubClient", "companyName": "GitHub, Inc.", - "version": "3.3.18", + "version": "3.4.1", "main": "./main.js", "repository": { "type": "git", @@ -32,7 +32,7 @@ "desktop-trampoline": "desktop/desktop-trampoline#v0.9.8", "dexie": "^3.2.2", "dompurify": "^2.3.3", - "dugite": "^2.7.0", + "dugite": "3.0.0-rc3", "electron-window-state": "^5.0.3", "event-kit": "^2.0.0", "focus-trap-react": "^8.1.0", diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index cd99ca63d65..3d9423ee831 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -278,6 +278,9 @@ export interface IAppState { /** The currently applied appearance (aka theme) */ readonly currentTheme: ApplicableTheme + /** The selected tab size preference */ + readonly selectedTabSize: number + /** * A map keyed on a user account (GitHub.com or GitHub Enterprise) * containing an object with repositories that the authenticated diff --git a/app/src/lib/ci-checks/ci-checks.ts b/app/src/lib/ci-checks/ci-checks.ts index ff29c01ac35..1efb276b1dc 100644 --- a/app/src/lib/ci-checks/ci-checks.ts +++ b/app/src/lib/ci-checks/ci-checks.ts @@ -1,15 +1,15 @@ +import { Account } from '../../models/account' +import { GitHubRepository } from '../../models/github-repository' import { - APICheckStatus, + API, APICheckConclusion, - IAPIWorkflowJobStep, + APICheckStatus, IAPIRefCheckRun, IAPIRefStatusItem, - API, + IAPIWorkflowJobStep, IAPIWorkflowJobs, IAPIWorkflowRun, } from '../api' -import { GitHubRepository } from '../../models/github-repository' -import { Account } from '../../models/account' import { supportsRetrieveActionWorkflowByCheckSuiteId } from '../endpoint-capabilities' import { formatLongPreciseDuration, @@ -286,7 +286,7 @@ export function isSuccess(check: IRefCheck) { * We use the check suite id as a proxy for determining what's * the "latest" of two check runs with the same name. */ -export function getLatestCheckRunsByName( +export function getLatestCheckRunsById( checkRuns: ReadonlyArray ): ReadonlyArray { const latestCheckRunsByName = new Map() @@ -301,7 +301,7 @@ export function getLatestCheckRunsByName( // feels hacky... but we don't have any other meta data on a check run that // differieates these. const nameAndHasPRs = - checkRun.name + + checkRun.id + (checkRun.pull_requests.length > 0 ? 'isPullRequestCheckRun' : 'isPushCheckRun') diff --git a/app/src/lib/editors/launch.ts b/app/src/lib/editors/launch.ts index e84adb33662..a40ed2452c4 100644 --- a/app/src/lib/editors/launch.ts +++ b/app/src/lib/editors/launch.ts @@ -15,10 +15,8 @@ export async function launchExternalEditor( ): Promise { const editorPath = editor.path const exists = await pathExists(editorPath) + const label = __DARWIN__ ? 'Settings' : 'Options' if (!exists) { - const label = __DARWIN__ - ? t('common.settings', 'Settings') - : t('common.options', 'Options') throw new ExternalEditorError( t( 'launch.error.could-not-find-executable', @@ -37,13 +35,28 @@ export async function launchExternalEditor( detached: true, } - if (editor.usesShell) { - spawn(`"${editorPath}"`, [`"${fullPath}"`], { ...opts, shell: true }) - } else if (__DARWIN__) { - // In macOS we can use `open`, which will open the right executable file - // for us, we only need the path to the editor .app folder. - spawn('open', ['-a', editorPath, fullPath], opts) - } else { - spawn(editorPath, [fullPath], opts) + try { + if (editor.usesShell) { + spawn(`"${editorPath}"`, [`"${fullPath}"`], { ...opts, shell: true }) + } else if (__DARWIN__) { + // In macOS we can use `open`, which will open the right executable file + // for us, we only need the path to the editor .app folder. + spawn('open', ['-a', editorPath, fullPath], opts) + } else { + spawn(editorPath, [fullPath], opts) + } + } catch (error) { + log.error(`Error while launching ${editor.editor}`, error) + if (error?.code === 'EACCES') { + throw new ExternalEditorError( + `GitHub Desktop doesn't have the proper permissions to start '${editor.editor}'. Please open ${label} and try another editor.`, + { openPreferences: true } + ) + } else { + throw new ExternalEditorError( + `Something went wrong while trying to start '${editor.editor}'. Please open ${label} and try another editor.`, + { openPreferences: true } + ) + } } } diff --git a/app/src/lib/editors/linux.ts b/app/src/lib/editors/linux.ts index 025b6df0e6b..0be8c2b858d 100644 --- a/app/src/lib/editors/linux.ts +++ b/app/src/lib/editors/linux.ts @@ -42,20 +42,33 @@ const editors: ILinuxExternalEditor[] = [ '/snap/bin/code', '/usr/bin/code', '/mnt/c/Program Files/Microsoft VS Code/bin/code', + '/var/lib/flatpak/app/com.visualstudio.code/current/active/export/bin/com.visualstudio.code', + '.local/share/flatpak/app/com.visualstudio.code/current/active/export/bin/com.visualstudio.code', ], }, { name: 'Visual Studio Code (Insiders)', - paths: ['/snap/bin/code-insiders', '/usr/bin/code-insiders'], + paths: [ + '/snap/bin/code-insiders', + '/usr/bin/code-insiders', + '/var/lib/flatpak/app/com.visualstudio.code.insiders/current/active/export/bin/com.visualstudio.code.insiders', + '.local/share/flatpak/app/com.visualstudio.code.insiders/current/active/export/bin/com.visualstudio.code.insiders', + ], }, { name: 'VSCodium', paths: [ '/usr/bin/codium', - '/var/lib/flatpak/app/com.vscodium.codium', + '/var/lib/flatpak/app/com.vscodium.codium/current/active/export/bin/com.vscodium.codium', '/usr/share/vscodium-bin/bin/codium', + '.local/share/flatpak/app/com.vscodium.codium/current/active/export/bin/com.vscodium.codium', + '/snap/bin/codium', ], }, + { + name: 'VSCodium (Insiders)', + paths: ['/usr/bin/codium-insiders'], + }, { name: 'Sublime Text', paths: ['/usr/bin/subl'], @@ -101,10 +114,25 @@ const editors: ILinuxExternalEditor[] = [ name: 'IntelliJ IDEA', paths: ['/snap/bin/idea', '.local/share/JetBrains/Toolbox/scripts/idea'], }, + { + name: 'IntelliJ IDEA Ultimate Edition', + paths: [ + '/snap/bin/intellij-idea-ultimate', + '.local/share/JetBrains/Toolbox/scripts/intellij-idea-ultimate', + ], + }, + { + name: 'IntelliJ Goland', + paths: [ + '/snap/bin/goland', + '.local/share/JetBrains/Toolbox/scripts/goland', + ], + }, { name: 'JetBrains PyCharm', paths: [ '/snap/bin/pycharm', + '/snap/bin/pycharm-professional', '.local/share/JetBrains/Toolbox/scripts/pycharm', ], }, @@ -139,10 +167,6 @@ const editors: ILinuxExternalEditor[] = [ name: 'Notepadqq', paths: ['/usr/bin/notepadqq'], }, - { - name: 'Geany', - paths: ['/usr/bin/geany'], - }, { name: 'Mousepad', paths: ['/usr/bin/mousepad'], @@ -151,6 +175,10 @@ const editors: ILinuxExternalEditor[] = [ name: 'Pulsar', paths: ['/usr/bin/pulsar'], }, + { + name: 'Pluma', + paths: ['/usr/bin/pluma'], + }, ] async function getAvailablePath(paths: string[]): Promise { diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index 1eeaac9bd63..1641b0b8c51 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -70,7 +70,7 @@ export function enableUpdateFromEmulatedX64ToARM64(): boolean { /** Should we allow resetting to a previous commit? */ export function enableResetToCommit(): boolean { - return enableBetaFeatures() + return true } /** Should we allow checking out a single commit? */ @@ -98,7 +98,7 @@ export const enableRepoRulesBeta = () => true export const enableCommitDetailsHeaderExpansion = () => true -export const enableDiffCheckMarksAndLinkUnderlines = enableBetaFeatures +export const enableDiffCheckMarksAndLinkUnderlines = () => true export const enableDiffCheckMarks = enableDiffCheckMarksAndLinkUnderlines export const enableGroupDiffCheckmarks = enableDiffCheckMarksAndLinkUnderlines diff --git a/app/src/lib/generic-git-auth.ts b/app/src/lib/generic-git-auth.ts index b1a9f0972be..51011e33256 100644 --- a/app/src/lib/generic-git-auth.ts +++ b/app/src/lib/generic-git-auth.ts @@ -1,107 +1,40 @@ -import { parseRemote } from './remote-parsing' import { getKeyForEndpoint } from './auth' import { TokenStore } from './stores/token-store' -const tryParseURLHostname = (url: string) => { - try { - return new URL(url).hostname - } catch { - return undefined - } -} - -/** Get the hostname to use for the given remote. */ -export function getGenericHostname(remoteURL: string): string { - const parsed = parseRemote(remoteURL) - if (parsed) { - if (parsed.protocol === 'https') { - return tryParseURLHostname(remoteURL) ?? parsed.hostname - } - - return parsed.hostname - } - - return tryParseURLHostname(remoteURL) ?? remoteURL -} - export const genericGitAuthUsernameKeyPrefix = 'genericGitAuth/username/' -function getKeyForUsername(hostname: string): string { - return `${genericGitAuthUsernameKeyPrefix}${hostname}` +function getKeyForUsername(endpoint: string): string { + return `${genericGitAuthUsernameKeyPrefix}${endpoint}` } /** Get the username for the host. */ -export function getGenericUsername(hostname: string): string | null { - const key = getKeyForUsername(hostname) +export function getGenericUsername(endpoint: string): string | null { + const key = getKeyForUsername(endpoint) return localStorage.getItem(key) } /** Set the username for the host. */ -export function setGenericUsername(hostname: string, username: string) { - const key = getKeyForUsername(hostname) +export function setGenericUsername(endpoint: string, username: string) { + const key = getKeyForUsername(endpoint) return localStorage.setItem(key, username) } /** Set the password for the username and host. */ export function setGenericPassword( - hostname: string, + endpoint: string, username: string, password: string ): Promise { - const key = getKeyForEndpoint(hostname) + const key = getKeyForEndpoint(endpoint) return TokenStore.setItem(key, username, password) } -/** Delete a generic credential */ -export function deleteGenericCredential(hostname: string, username: string) { - localStorage.removeItem(getKeyForUsername(hostname)) - return TokenStore.deleteItem(getKeyForEndpoint(hostname), username) -} - -/** - * Migrate generic git credentials from the old format which could include - * a path to the new format which only includes the hostname. - */ -export async function removePathFromGenericGitAuthCreds() { - try { - for (const key of Object.keys(localStorage)) { - if (key.startsWith(genericGitAuthUsernameKeyPrefix)) { - const oldHostname = key.substring( - genericGitAuthUsernameKeyPrefix.length - ) - const slashIx = oldHostname.indexOf('/') - if (slashIx === -1) { - continue - } - - const newHostname = oldHostname.substring(0, slashIx) - log.info(`Migrating credentials ${oldHostname} → ${newHostname}`) - - // Don't overwrite existing credentials - if (getGenericUsername(newHostname)) { - continue - } - - const username = getGenericUsername(oldHostname) +/** Get the password for the given username and host. */ +export const getGenericPassword = (endpoint: string, username: string) => + TokenStore.getItem(getKeyForEndpoint(endpoint), username) - if (!username) { - continue - } - - const password = await TokenStore.getItem( - getKeyForEndpoint(oldHostname), - username - ) - - if (password) { - setGenericUsername(newHostname, username) - setGenericPassword(newHostname, username, password) - - deleteGenericCredential(oldHostname, username) - } - } - } - } catch { - log.error('Failed to remove path from generic git credentials') - } +/** Delete a generic credential */ +export function deleteGenericCredential(endpoint: string, username: string) { + localStorage.removeItem(getKeyForUsername(endpoint)) + return TokenStore.deleteItem(getKeyForEndpoint(endpoint), username) } diff --git a/app/src/lib/git/authentication.ts b/app/src/lib/git/authentication.ts index 997c3ffa4f9..a6961d18a62 100644 --- a/app/src/lib/git/authentication.ts +++ b/app/src/lib/git/authentication.ts @@ -1,7 +1,7 @@ import { GitError as DugiteError } from 'dugite' /** Get the environment for authenticating remote operations. */ -export function envForAuthentication(): Object { +export function envForAuthentication(): Record { return { // supported since Git 2.3, this is used to ensure we never interactively prompt // for credentials - even as a fallback diff --git a/app/src/lib/git/branch.ts b/app/src/lib/git/branch.ts index 936c792191a..a4a394fc3cf 100644 --- a/app/src/lib/git/branch.ts +++ b/app/src/lib/git/branch.ts @@ -1,16 +1,12 @@ import { git, gitNetworkArguments } from './core' import { Repository } from '../../models/repository' import { Branch } from '../../models/branch' -import { IGitAccount } from '../../models/git-account' import { formatAsLocalRef } from './refs' import { deleteRef } from './update-ref' import { GitError as DugiteError } from 'dugite' -import { getRemoteURL } from './remote' -import { - envForRemoteOperation, - getFallbackUrlForProxyResolve, -} from './environment' +import { envForRemoteOperation } from './environment' import { createForEachRefParser } from './git-delimiter-parser' +import { IRemote } from '../../models/remote' /** * Create a new branch from the given start point. @@ -72,30 +68,20 @@ export async function deleteLocalBranch( */ export async function deleteRemoteBranch( repository: Repository, - account: IGitAccount | null, - remoteName: string, + remote: IRemote, remoteBranchName: string ): Promise { - const remoteUrl = - (await getRemoteURL(repository, remoteName).catch(err => { - // If we can't get the URL then it's very unlikely Git will be able to - // either and the push will fail. The URL is only used to resolve the - // proxy though so it's not critical. - log.error(`Could not resolve remote url for remote ${remoteName}`, err) - return null - })) || getFallbackUrlForProxyResolve(account, repository) - const args = [ ...gitNetworkArguments(), 'push', - remoteName, + remote.name, `:${remoteBranchName}`, ] // If the user is not authenticated, the push is going to fail // Let this propagate and leave it to the caller to handle const result = await git(args, repository.path, 'deleteRemoteBranch', { - env: await envForRemoteOperation(account, remoteUrl), + env: await envForRemoteOperation(remote.url), expectedErrors: new Set([DugiteError.BranchDeletionFailed]), }) @@ -104,7 +90,7 @@ export async function deleteRemoteBranch( // error we can safely remove our remote ref which is what would // happen if the push didn't fail. if (result.gitError === DugiteError.BranchDeletionFailed) { - const ref = `refs/remotes/${remoteName}/${remoteBranchName}` + const ref = `refs/remotes/${remote.name}/${remoteBranchName}` await deleteRef(repository, ref) } diff --git a/app/src/lib/git/checkout.ts b/app/src/lib/git/checkout.ts index c1691ca0c75..563d64de1f8 100644 --- a/app/src/lib/git/checkout.ts +++ b/app/src/lib/git/checkout.ts @@ -2,7 +2,6 @@ import { git, IGitExecutionOptions, gitNetworkArguments } from './core' import { Repository } from '../../models/repository' import { Branch, BranchType } from '../../models/branch' import { ICheckoutProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { CheckoutProgressParser, executionOptionsWithProgress, @@ -17,6 +16,7 @@ import { WorkingDirectoryFileChange } from '../../models/status' import { ManualConflictResolution } from '../../models/manual-conflict-resolution' import { t } from 'i18next' import { CommitOneLine, shortenSHA } from '../../models/commit' +import { IRemote } from '../../models/remote' export type ProgressCallback = (progress: ICheckoutProgress) => void @@ -47,16 +47,15 @@ async function getBranchCheckoutArgs(branch: Branch) { async function getCheckoutOpts( repository: Repository, - account: IGitAccount | null, title: string, target: string, + currentRemote: IRemote | null, progressCallback?: ProgressCallback, initialDescription?: string ): Promise { const opts: IGitExecutionOptions = { env: await envForRemoteOperation( - account, - getFallbackUrlForProxyResolve(account, repository) + getFallbackUrlForProxyResolve(repository, currentRemote) ), expectedErrors: AuthenticationErrors, } @@ -112,17 +111,17 @@ async function getCheckoutOpts( */ export async function checkoutBranch( repository: Repository, - account: IGitAccount | null, branch: Branch, + currentRemote: IRemote | null, progressCallback?: ProgressCallback ): Promise { const opts = await getCheckoutOpts( repository, - account, t('checkout.checking-out-branch', `Checking out branch {{0}}`, { 0: branch.name, }), branch.name, + currentRemote, progressCallback, __DARWIN__ ? t('checkout.switching-to-branch-darwin', 'Switching to Branch') @@ -156,8 +155,8 @@ export async function checkoutBranch( */ export async function checkoutCommit( repository: Repository, - account: IGitAccount | null, commit: CommitOneLine, + currentRemote: IRemote | null, progressCallback?: ProgressCallback ): Promise { const title = __DARWIN__ @@ -165,9 +164,9 @@ export async function checkoutCommit( : t('checkout.checking-out-commit', 'Checking out commit') const opts = await getCheckoutOpts( repository, - account, title, shortenSHA(commit.sha), + currentRemote, progressCallback ) diff --git a/app/src/lib/git/cherry-pick.ts b/app/src/lib/git/cherry-pick.ts index 2ddacd68053..63415b559bf 100644 --- a/app/src/lib/git/cherry-pick.ts +++ b/app/src/lib/git/cherry-pick.ts @@ -157,7 +157,7 @@ export async function cherryPick( ) } - // --keep-redundant-commits follows pattern of making sure someone cherry + // --empty=keep follows pattern of making sure someone cherry // picked commit summaries appear in target branch history even tho they may // be empty. This flag also results in the ability to cherry pick empty // commits (thus, --allow-empty is not required.) @@ -167,12 +167,7 @@ export async function cherryPick( // there could be multiple empty commits. I.E. If user does a range that // includes commits from that merge. const result = await git( - [ - 'cherry-pick', - ...commits.map(c => c.sha), - '--keep-redundant-commits', - '-m 1', - ], + ['cherry-pick', ...commits.map(c => c.sha), '--empty=keep', '-m 1'], repository.path, 'cherry-pick', baseOptions @@ -465,12 +460,8 @@ export async function continueCherryPick( return parseCherryPickResult(result) } - // --keep-redundant-commits follows pattern of making sure someone cherry - // picked commit summaries appear in target branch history even tho they may - // be empty. This flag also results in the ability to cherry pick empty - // commits (thus, --allow-empty is not required.) const result = await git( - ['cherry-pick', '--continue', '--keep-redundant-commits'], + ['cherry-pick', '--continue'], repository.path, 'continueCherryPick', options diff --git a/app/src/lib/git/clone.ts b/app/src/lib/git/clone.ts index f10e08a807a..6035c6851b9 100644 --- a/app/src/lib/git/clone.ts +++ b/app/src/lib/git/clone.ts @@ -32,7 +32,7 @@ export async function clone( progressCallback?: (progress: ICloneProgress) => void ): Promise { const env = { - ...(await envForRemoteOperation(options.account, url)), + ...(await envForRemoteOperation(url)), GIT_CLONE_PROTECTION_ACTIVE: 'false', } diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 90d9fc7e98f..6fda3094024 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -168,7 +168,7 @@ export async function git( // from a terminal or if the system environment variables // have TERM set Git won't consider us as a smart terminal. // See https://github.com/git/git/blob/a7312d1a2/editor.c#L11-L15 - opts.env = { TERM: 'dumb', ...combinedEnv } as object + opts.env = { TERM: 'dumb', ...combinedEnv } const commandName = `${name}: git ${args.join(' ')}` diff --git a/app/src/lib/git/environment.ts b/app/src/lib/git/environment.ts index f1fcda246b9..fedefb09aaa 100644 --- a/app/src/lib/git/environment.ts +++ b/app/src/lib/git/environment.ts @@ -1,8 +1,11 @@ import { envForAuthentication } from './authentication' -import { IGitAccount } from '../../models/git-account' import { resolveGitProxy } from '../resolve-git-proxy' -import { getDotComAPIEndpoint } from '../api' -import { Repository } from '../../models/repository' +import { getHTMLURL } from '../api' +import { + Repository, + isRepositoryWithGitHubRepository, +} from '../../models/repository' +import { IRemote } from '../../models/remote' /** * For many remote operations it's well known what the primary remote @@ -17,25 +20,34 @@ import { Repository } from '../../models/repository' * be on a different server as well. That's too advanced for our usage * at the moment though so we'll just need to figure out some reasonable * url to fall back on. + * + * @param branchName If the operation we're about to undertake is related to a + * local ref (i.e branch) then we can use that to resolve its + * upstream tracking branch (and thereby its remote) and use + * that as the probable url to resolve a proxy for. */ export function getFallbackUrlForProxyResolve( - account: IGitAccount | null, - repository: Repository + repository: Repository, + currentRemote: IRemote | null ) { - // If we've got an account with an endpoint that means we've already done the - // heavy lifting to figure out what the most likely endpoint is gonna be - // so we'll try to leverage that. - if (account !== null) { - // A GitHub.com Account will have api.github.com as its endpoint - return account.endpoint === getDotComAPIEndpoint() - ? 'https://github.com' - : account.endpoint + // We used to use account.endpoint here but we look up account by the + // repository endpoint (see getAccountForRepository) so we can skip the use + // of the account here and just use the repository endpoint directly. + if (isRepositoryWithGitHubRepository(repository)) { + return getHTMLURL(repository.gitHubRepository.endpoint) } - if (repository.gitHubRepository !== null) { - if (repository.gitHubRepository.cloneURL !== null) { - return repository.gitHubRepository.cloneURL - } + // This is a carry-over from the old code where we would use the current + // remote to resolve an account and then use that account's endpoint here. + // We've since removed the need to pass an account down here but unfortunately + // that means we need to pass the current remote instead. Note that ideally + // this should be looking up the remote url either based on the currently + // checked out branch, the upstream tracking branch of the branch being + // checked out, or the default remote if neither of those are available. + // Doing so by shelling out to Git here was deemed to costly and in order to + // finish this refactor we've opted to replicate the previous behavior here. + if (currentRemote) { + return currentRemote.url } // If all else fails let's assume that whatever network resource @@ -61,10 +73,7 @@ export function getFallbackUrlForProxyResolve( * pointing to another host entirely. Used to resolve which * proxy (if any) should be used for the operation. */ -export async function envForRemoteOperation( - account: IGitAccount | null, - remoteUrl: string -) { +export async function envForRemoteOperation(remoteUrl: string) { return { ...envForAuthentication(), ...(await envForProxy(remoteUrl)), @@ -85,7 +94,7 @@ export async function envForProxy( remoteUrl: string, env: NodeJS.ProcessEnv = process.env, resolve: (url: string) => Promise = resolveGitProxy -): Promise { +): Promise | undefined> { const protocolMatch = /^(https?):\/\//i.exec(remoteUrl) // We can only resolve and use a proxy for the protocols where cURL diff --git a/app/src/lib/git/fetch.ts b/app/src/lib/git/fetch.ts index 61cd6b50b0e..0d1f1d12cc3 100644 --- a/app/src/lib/git/fetch.ts +++ b/app/src/lib/git/fetch.ts @@ -1,6 +1,5 @@ import { git, IGitExecutionOptions, gitNetworkArguments } from './core' import { Repository } from '../../models/repository' -import { IGitAccount } from '../../models/git-account' import { IFetchProgress } from '../../models/progress' import { FetchProgressParser, executionOptionsWithProgress } from '../progress' import { enableRecurseSubmodulesFlag } from '../feature-flag' @@ -10,9 +9,7 @@ import { envForRemoteOperation } from './environment' import { t } from 'i18next' async function getFetchArgs( - repository: Repository, remote: string, - account: IGitAccount | null, progressCallback?: (progress: IFetchProgress) => void ) { if (enableRecurseSubmodulesFlag()) { @@ -58,14 +55,13 @@ async function getFetchArgs( */ export async function fetch( repository: Repository, - account: IGitAccount | null, remote: IRemote, progressCallback?: (progress: IFetchProgress) => void, isBackgroundTask = false ): Promise { let opts: IGitExecutionOptions = { successExitCodes: new Set([0]), - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), } if (progressCallback) { @@ -104,12 +100,7 @@ export async function fetch( progressCallback({ kind, title, value: 0, remote: remote.name }) } - const args = await getFetchArgs( - repository, - remote.name, - account, - progressCallback - ) + const args = await getFetchArgs(remote.name, progressCallback) await git(args, repository.path, 'fetch', opts) } @@ -117,7 +108,6 @@ export async function fetch( /** Fetch a given refspec from the given remote. */ export async function fetchRefspec( repository: Repository, - account: IGitAccount | null, remote: IRemote, refspec: string ): Promise { @@ -127,7 +117,7 @@ export async function fetchRefspec( 'fetchRefspec', { successExitCodes: new Set([0, 128]), - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), } ) } diff --git a/app/src/lib/git/pull.ts b/app/src/lib/git/pull.ts index a3e188bc067..cc9b2e7063d 100644 --- a/app/src/lib/git/pull.ts +++ b/app/src/lib/git/pull.ts @@ -7,7 +7,6 @@ import { } from './core' import { Repository } from '../../models/repository' import { IPullProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { PullProgressParser, executionOptionsWithProgress } from '../progress' import { AuthenticationErrors } from './authentication' import { enableRecurseSubmodulesFlag } from '../feature-flag' @@ -19,7 +18,6 @@ import { t } from 'i18next' async function getPullArgs( repository: Repository, remote: string, - account: IGitAccount | null, progressCallback?: (progress: IPullProgress) => void ) { const divergentPathArgs = await getDefaultPullDivergentBranchArguments( @@ -61,12 +59,11 @@ async function getPullArgs( */ export async function pull( repository: Repository, - account: IGitAccount | null, remote: IRemote, progressCallback?: (progress: IPullProgress) => void ): Promise { let opts: IGitExecutionOptions = { - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), expectedErrors: AuthenticationErrors, } @@ -107,12 +104,7 @@ export async function pull( progressCallback({ kind, title, value: 0, remote: remote.name }) } - const args = await getPullArgs( - repository, - remote.name, - account, - progressCallback - ) + const args = await getPullArgs(repository, remote.name, progressCallback) const result = await git(args, repository.path, 'pull', opts) if (result.gitErrorDescription) { diff --git a/app/src/lib/git/push.ts b/app/src/lib/git/push.ts index eb81701baf1..b392f5e2409 100644 --- a/app/src/lib/git/push.ts +++ b/app/src/lib/git/push.ts @@ -8,7 +8,6 @@ import { } from './core' import { Repository } from '../../models/repository' import { IPushProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { PushProgressParser, executionOptionsWithProgress } from '../progress' import { AuthenticationErrors } from './authentication' import { IRemote } from '../../models/remote' @@ -50,7 +49,6 @@ export type PushOptions = { */ export async function push( repository: Repository, - account: IGitAccount | null, remote: IRemote, localBranch: string, remoteBranch: string | null, @@ -80,7 +78,7 @@ export async function push( expectedErrors.add(DugiteError.ProtectedBranchForcePush) let opts: IGitExecutionOptions = { - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), expectedErrors, } diff --git a/app/src/lib/git/remote.ts b/app/src/lib/git/remote.ts index 20b9e0f0b72..e264c842d48 100644 --- a/app/src/lib/git/remote.ts +++ b/app/src/lib/git/remote.ts @@ -4,7 +4,6 @@ import { GitError } from 'dugite' import { Repository } from '../../models/repository' import { IRemote } from '../../models/remote' import { envForRemoteOperation } from './environment' -import { IGitAccount } from '../../models/git-account' import { getSymbolicRef } from './refs' import { gitNetworkArguments } from '.' @@ -101,13 +100,12 @@ export async function getRemoteURL( */ export async function updateRemoteHEAD( repository: Repository, - account: IGitAccount | null, remote: IRemote, isBackgroundTask: boolean ): Promise { const options = { successExitCodes: new Set([0, 1, 128]), - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), isBackgroundTask, } diff --git a/app/src/lib/git/rev-parse.ts b/app/src/lib/git/rev-parse.ts index 171c27fa0c8..27414411a5e 100644 --- a/app/src/lib/git/rev-parse.ts +++ b/app/src/lib/git/rev-parse.ts @@ -56,3 +56,23 @@ export async function getRepositoryType(path: string): Promise { throw err } } + +export async function getUpstreamRefForRef(path: string, ref?: string) { + const rev = (ref ?? '') + '@{upstream}' + const args = ['rev-parse', '--symbolic-full-name', rev] + const opts = { successExitCodes: new Set([0, 128]) } + const result = await git(args, path, 'getUpstreamRefForRef', opts) + + return result.exitCode === 0 ? result.stdout.trim() : null +} + +export async function getUpstreamRemoteNameForRef(path: string, ref?: string) { + const remoteRef = await getUpstreamRefForRef(path, ref) + return remoteRef?.match(/^refs\/remotes\/([^/]+)\//)?.[1] ?? null +} + +export const getCurrentUpstreamRef = (path: string) => + getUpstreamRefForRef(path) + +export const getCurrentUpstreamRemoteName = (path: string) => + getUpstreamRemoteNameForRef(path) diff --git a/app/src/lib/git/revert.ts b/app/src/lib/git/revert.ts index cee54c28d9f..708a452a75d 100644 --- a/app/src/lib/git/revert.ts +++ b/app/src/lib/git/revert.ts @@ -3,7 +3,6 @@ import { git, gitNetworkArguments, IGitExecutionOptions } from './core' import { Repository } from '../../models/repository' import { Commit } from '../../models/commit' import { IRevertProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { executionOptionsWithProgress } from '../progress/from-process' import { RevertProgressParser } from '../progress/revert' @@ -11,6 +10,7 @@ import { envForRemoteOperation, getFallbackUrlForProxyResolve, } from './environment' +import { IRemote } from '../../models/remote' /** * Creates a new commit that reverts the changes of a previous commit @@ -22,7 +22,7 @@ import { export async function revertCommit( repository: Repository, commit: Commit, - account: IGitAccount | null, + currentRemote: IRemote | null, progressCallback?: (progress: IRevertProgress) => void ) { const args = [...gitNetworkArguments(), 'revert'] @@ -35,8 +35,7 @@ export async function revertCommit( let opts: IGitExecutionOptions = {} if (progressCallback) { const env = await envForRemoteOperation( - account, - getFallbackUrlForProxyResolve(account, repository) + getFallbackUrlForProxyResolve(repository, currentRemote) ) opts = await executionOptionsWithProgress( { env, trackLFSProgress: true }, diff --git a/app/src/lib/git/tag.ts b/app/src/lib/git/tag.ts index 50f8d38c79b..5e2968a732f 100644 --- a/app/src/lib/git/tag.ts +++ b/app/src/lib/git/tag.ts @@ -1,6 +1,5 @@ import { git, gitNetworkArguments } from './core' import { Repository } from '../../models/repository' -import { IGitAccount } from '../../models/git-account' import { IRemote } from '../../models/remote' import { envForRemoteOperation } from './environment' @@ -86,7 +85,6 @@ export async function getAllTags( */ export async function fetchTagsToPush( repository: Repository, - account: IGitAccount | null, remote: IRemote, branchName: string ): Promise> { @@ -102,7 +100,7 @@ export async function fetchTagsToPush( ] const result = await git(args, repository.path, 'fetchTagsToPush', { - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), successExitCodes: new Set([0, 1, 128]), }) diff --git a/app/src/lib/ipc-shared.ts b/app/src/lib/ipc-shared.ts index e47dc6f4f03..8b6387b37e8 100644 --- a/app/src/lib/ipc-shared.ts +++ b/app/src/lib/ipc-shared.ts @@ -25,6 +25,7 @@ import { DesktopAliveEvent } from './stores/alive-store' */ export type RequestChannels = { 'select-all-window-contents': () => void + 'dialog-did-open': () => void 'update-menu-state': ( state: Array<{ id: MenuIDs; state: IMenuItemState }> ) => void diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index bbcf7c9008f..118d5ee167a 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -70,7 +70,6 @@ import { IMultiCommitOperationProgress, } from '../../models/progress' import { Popup, PopupType } from '../../models/popup' -import { IGitAccount } from '../../models/git-account' import { themeChangeMonitor } from '../../ui/lib/theme-change-monitor' import { getAppPath } from '../../ui/lib/app-proxy' import { @@ -135,11 +134,6 @@ import { import { assertNever, fatalError, forceUnwrap } from '../fatal-error' import { formatCommitMessage } from '../format-commit-message' -import { - getGenericHostname, - getGenericUsername, - removePathFromGenericGitAuthCreds, -} from '../generic-git-auth' import { getAccountForRepository } from '../get-account-for-repository' import { abortMerge, @@ -187,6 +181,7 @@ import { getBranchMergeBaseChangedFiles, getBranchMergeBaseDiff, checkoutCommit, + getRemoteURL, } from '../git' import { installGlobalLFSFilters, @@ -397,6 +392,9 @@ const hideWhitespaceInPullRequestDiffKey = const commitSpellcheckEnabledDefault = true const commitSpellcheckEnabledKey = 'commit-spellcheck-enabled' +export const tabSizeDefault: number = 8 +const tabSizeKey: string = 'tab-size' + const shellKey = 'shell' const repositoryIndicatorsEnabledKey = 'enable-repository-indicators' @@ -529,6 +527,7 @@ export class AppStore extends TypedBaseStore { private selectedBranchesTab = BranchesTab.Branches private selectedTheme = ApplicationTheme.System private currentTheme: ApplicableTheme = ApplicationTheme.Light + private selectedTabSize = tabSizeDefault private useWindowsOpenSSH: boolean = false @@ -609,8 +608,6 @@ export class AppStore extends TypedBaseStore { this.getResolvedExternalEditor ) - removePathFromGenericGitAuthCreds() - // We're considering flipping the default value and have new users // start off with repository indicators disabled. As such we'll start // persisting the current default to localstorage right away so we @@ -1028,6 +1025,7 @@ export class AppStore extends TypedBaseStore { selectedBranchesTab: this.selectedBranchesTab, selectedTheme: this.selectedTheme, currentTheme: this.currentTheme, + selectedTabSize: this.selectedTabSize, apiRepositories: this.apiRepositoriesStore.getState(), useWindowsOpenSSH: this.useWindowsOpenSSH, showCommitLengthWarning: this.showCommitLengthWarning, @@ -2225,6 +2223,8 @@ export class AppStore extends TypedBaseStore { this.currentTheme = await getCurrentlyAppliedTheme() + this.selectedTabSize = getNumber(tabSizeKey, tabSizeDefault) + themeChangeMonitor.onThemeChanged(theme => { this.currentTheme = theme this.emitUpdate() @@ -3561,12 +3561,12 @@ export class AppStore extends TypedBaseStore { * of the current branch to its upstream tracking branch. */ private fetchForRepositoryIndicator(repo: Repository) { - return this.withAuthenticatingUser(repo, async (repo, account) => { + return this.withRefreshedGitHubRepository(repo, async repo => { const isBackgroundTask = true const gitStore = this.gitStoreCache.get(repo) await this.withPushPullFetch(repo, () => - gitStore.fetch(account, isBackgroundTask, progress => + gitStore.fetch(isBackgroundTask, progress => this.updatePushPullFetchProgress(repo, progress) ) ) @@ -3874,11 +3874,11 @@ export class AppStore extends TypedBaseStore { } } - return this.withAuthenticatingUser(repository, (repository, account) => { + return this.withRefreshedGitHubRepository(repository, repository => { // We always want to end with refreshing the repository regardless of // whether the checkout succeeded or not in order to present the most // up-to-date information to the user. - return this.checkoutImplementation(repository, branch, account, strategy) + return this.checkoutImplementation(repository, branch, strategy) .then(() => this.onSuccessfulCheckout(repository, branch)) .catch(e => this.emitError(new CheckoutError(e, repository, branch))) .then(() => this.refreshAfterCheckout(repository, branch.name)) @@ -3890,15 +3890,16 @@ export class AppStore extends TypedBaseStore { private checkoutImplementation( repository: Repository, branch: Branch, - account: IGitAccount | null, strategy: UncommittedChangesStrategy ) { + const { currentRemote } = this.gitStoreCache.get(repository) + if (strategy === UncommittedChangesStrategy.StashOnCurrentBranch) { - return this.checkoutAndLeaveChanges(repository, branch, account) + return this.checkoutAndLeaveChanges(repository, branch, currentRemote) } else if (strategy === UncommittedChangesStrategy.MoveToNewBranch) { - return this.checkoutAndBringChanges(repository, branch, account) + return this.checkoutAndBringChanges(repository, branch, currentRemote) } else { - return this.checkoutIgnoringChanges(repository, branch, account) + return this.checkoutIgnoringChanges(repository, branch, currentRemote) } } @@ -3906,9 +3907,9 @@ export class AppStore extends TypedBaseStore { private async checkoutIgnoringChanges( repository: Repository, branch: Branch, - account: IGitAccount | null + currentRemote: IRemote | null ) { - await checkoutBranch(repository, account, branch, progress => { + await checkoutBranch(repository, branch, currentRemote, progress => { this.updateCheckoutProgress(repository, progress) }) } @@ -3921,7 +3922,7 @@ export class AppStore extends TypedBaseStore { private async checkoutAndLeaveChanges( repository: Repository, branch: Branch, - account: IGitAccount | null + currentRemote: IRemote | null ) { const repositoryState = this.repositoryStateCache.get(repository) const { workingDirectory } = repositoryState.changesState @@ -3932,7 +3933,7 @@ export class AppStore extends TypedBaseStore { this.statsStore.increment('stashCreatedOnCurrentBranchCount') } - return this.checkoutIgnoringChanges(repository, branch, account) + return this.checkoutIgnoringChanges(repository, branch, currentRemote) } /** @@ -3948,10 +3949,10 @@ export class AppStore extends TypedBaseStore { private async checkoutAndBringChanges( repository: Repository, branch: Branch, - account: IGitAccount | null + currentRemote: IRemote | null ) { try { - await this.checkoutIgnoringChanges(repository, branch, account) + await this.checkoutIgnoringChanges(repository, branch, currentRemote) } catch (checkoutError) { if (!isLocalChangesOverwrittenError(checkoutError)) { throw checkoutError @@ -3969,7 +3970,7 @@ export class AppStore extends TypedBaseStore { throw checkoutError } - await this.checkoutIgnoringChanges(repository, branch, account) + await this.checkoutIgnoringChanges(repository, branch, currentRemote) await popStashEntry(repository, stash.stashSha) this.statsStore.increment('changesTakenToNewBranchCount') @@ -4032,6 +4033,7 @@ export class AppStore extends TypedBaseStore { const repositoryState = this.repositoryStateCache.get(repository) const { branchesState } = repositoryState const { tip } = branchesState + const { currentRemote } = this.gitStoreCache.get(repository) // No point in checking out the currently checked out commit. if ( @@ -4041,11 +4043,15 @@ export class AppStore extends TypedBaseStore { return repository } - return this.withAuthenticatingUser(repository, (repository, account) => { + return this.withRefreshedGitHubRepository(repository, repository => { // We always want to end with refreshing the repository regardless of // whether the checkout succeeded or not in order to present the most // up-to-date information to the user. - return this.checkoutCommitDefaultBehaviour(repository, commit, account) + return this.checkoutCommitDefaultBehaviour( + repository, + commit, + currentRemote + ) .catch(e => this.emitError(new Error(e))) .then(() => this.refreshAfterCheckout(repository, shortenSHA(commit.sha)) @@ -4057,9 +4063,9 @@ export class AppStore extends TypedBaseStore { private async checkoutCommitDefaultBehaviour( repository: Repository, commit: CommitOneLine, - account: IGitAccount | null + currentRemote: IRemote | null ) { - await checkoutCommit(repository, account, commit, progress => { + await checkoutCommit(repository, commit, currentRemote, progress => { this.updateCheckoutProgress(repository, progress) }) } @@ -4239,8 +4245,8 @@ export class AppStore extends TypedBaseStore { includeUpstream?: boolean, toCheckout?: Branch | null ): Promise { - return this.withAuthenticatingUser(repository, async (r, account) => { - const gitStore = this.gitStoreCache.get(r) + return this.withRefreshedGitHubRepository(repository, async repository => { + const gitStore = this.gitStoreCache.get(repository) // If solely a remote branch, there is no need to checkout a branch. if (branch.type === BranchType.Remote) { @@ -4253,8 +4259,18 @@ export class AppStore extends TypedBaseStore { ) } + const remote = + gitStore.remotes.find(r => r.name === remoteName) ?? + (await getRemoteURL(repository, remoteName) + .then(url => (url ? { name: remoteName, url } : undefined)) + .catch(e => log.debug(`Could not get remote URL`, e))) + + if (remote === undefined) { + throw new Error(`Could not determine remote url from: ${branch.ref}.`) + } + await gitStore.performFailableOperation(() => - deleteRemoteBranch(r, account, remoteName, nameWithoutRemote) + deleteRemoteBranch(repository, remote, nameWithoutRemote) ) // We log the remote branch's sha so that the user can recover it. @@ -4262,17 +4278,17 @@ export class AppStore extends TypedBaseStore { `Deleted branch ${branch.upstreamWithoutRemote} (was ${tip.sha})` ) - return this._refreshRepository(r) + return this._refreshRepository(repository) } // If a local branch, user may have the branch to delete checked out and // we need to switch to a different branch (default or recent). const branchToCheckout = - toCheckout ?? this.getBranchToCheckoutAfterDelete(branch, r) + toCheckout ?? this.getBranchToCheckoutAfterDelete(branch, repository) if (branchToCheckout !== null) { await gitStore.performFailableOperation(() => - checkoutBranch(r, account, branchToCheckout) + checkoutBranch(repository, branchToCheckout, gitStore.currentRemote) ) } @@ -4280,12 +4296,11 @@ export class AppStore extends TypedBaseStore { return this.deleteLocalBranchAndUpstreamBranch( repository, branch, - account, includeUpstream ) }) - return this._refreshRepository(r) + return this._refreshRepository(repository) }) } @@ -4296,7 +4311,6 @@ export class AppStore extends TypedBaseStore { private async deleteLocalBranchAndUpstreamBranch( repository: Repository, branch: Branch, - account: IGitAccount | null, includeUpstream?: boolean ): Promise { await deleteLocalBranch(repository, branch.name) @@ -4306,12 +4320,20 @@ export class AppStore extends TypedBaseStore { branch.upstreamRemoteName !== null && branch.upstreamWithoutRemote !== null ) { - await deleteRemoteBranch( - repository, - account, - branch.upstreamRemoteName, - branch.upstreamWithoutRemote - ) + const gitStore = this.gitStoreCache.get(repository) + const remoteName = branch.upstreamRemoteName + + const remote = + gitStore.remotes.find(r => r.name === remoteName) ?? + (await getRemoteURL(repository, remoteName) + .then(url => (url ? { name: remoteName, url } : undefined)) + .catch(e => log.debug(`Could not get remote URL`, e))) + + if (!remote) { + throw new Error(`Could not determine remote url from: ${branch.ref}.`) + } + + await deleteRemoteBranch(repository, remote, branch.upstreamWithoutRemote) } return } @@ -4360,14 +4382,13 @@ export class AppStore extends TypedBaseStore { repository: Repository, options?: PushOptions ): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performPush(repository, account, options) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performPush(repository, options) }) } private async performPush( repository: Repository, - account: IGitAccount | null, options?: PushOptions ): Promise { const state = this.repositoryStateCache.get(repository) @@ -4473,7 +4494,6 @@ export class AppStore extends TypedBaseStore { async () => { await pushRepo( repository, - account, safeRemote, branch.name, branch.upstreamWithoutRemote, @@ -4489,17 +4509,12 @@ export class AppStore extends TypedBaseStore { ) gitStore.clearTagsToPush() - await gitStore.fetchRemotes( - account, - [safeRemote], - false, - fetchProgress => { - this.updatePushPullFetchProgress(repository, { - ...fetchProgress, - value: pushWeight + fetchProgress.value * fetchWeight, - }) - } - ) + await gitStore.fetchRemotes([safeRemote], false, fetchProgress => { + this.updatePushPullFetchProgress(repository, { + ...fetchProgress, + value: pushWeight + fetchProgress.value * fetchWeight, + }) + }) const refreshTitle = __DARWIN__ ? t( @@ -4600,16 +4615,13 @@ export class AppStore extends TypedBaseStore { } public async _pull(repository: Repository): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performPull(repository, account) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performPull(repository) }) } /** This shouldn't be called directly. See `Dispatcher`. */ - private async performPull( - repository: Repository, - account: IGitAccount | null - ): Promise { + private async performPull(repository: Repository): Promise { return this.withPushPullFetch(repository, async () => { const gitStore = this.gitStoreCache.get(repository) const remote = gitStore.currentRemote @@ -4684,7 +4696,7 @@ export class AppStore extends TypedBaseStore { const pullSucceeded = await gitStore.performFailableOperation( async () => { - await pullRepo(repository, account, remote, progress => { + await pullRepo(repository, remote, progress => { this.updatePushPullFetchProgress(repository, { ...progress, value: progress.value * pullWeight, @@ -4704,8 +4716,8 @@ export class AppStore extends TypedBaseStore { // Updating the local HEAD symref isn't critical so we don't want // to show an error message to the user and have them retry the // entire pull operation if it fails. - await updateRemoteHEAD(repository, account, remote, false).catch( - e => log.error('Failed updating remote HEAD', e) + await updateRemoteHEAD(repository, remote, false).catch(e => + log.error('Failed updating remote HEAD', e) ) } @@ -4786,7 +4798,7 @@ export class AppStore extends TypedBaseStore { // skip pushing if the current branch is a detached HEAD or the repository // is unborn if (gitStore.tip.kind === TipState.Valid) { - await this.performPush(repository, account) + await this.performPush(repository) } await gitStore.refreshDefaultBranch() @@ -4794,47 +4806,16 @@ export class AppStore extends TypedBaseStore { return this.repositoryWithRefreshedGitHubRepository(repository) } - private getAccountForRemoteURL(remote: string): IGitAccount | null { - const account = matchGitHubRepository(this.accounts, remote)?.account - if (account !== undefined) { - const hasValidToken = - account.token.length > 0 ? 'has token' : 'empty token' - log.info( - `[AppStore.getAccountForRemoteURL] account found for remote: ${remote} - ${account.login} (${hasValidToken})` - ) - return account - } - - const hostname = getGenericHostname(remote) - const username = getGenericUsername(hostname) - if (username != null) { - log.info( - `[AppStore.getAccountForRemoteURL] found generic credentials for '${hostname}' and '${username}'` - ) - return { login: username, endpoint: hostname } - } - - log.info( - `[AppStore.getAccountForRemoteURL] no generic credentials found for '${remote}'` - ) - - return null - } - /** This shouldn't be called directly. See `Dispatcher`. */ public _clone( url: string, path: string, - options?: { branch?: string; defaultBranch?: string } + options: { branch?: string; defaultBranch?: string } = {} ): { promise: Promise repository: CloningRepository } { - const account = this.getAccountForRemoteURL(url) - const promise = this.cloningRepositoriesStore.clone(url, path, { - ...options, - account, - }) + const promise = this.cloningRepositoriesStore.clone(url, path, options) const repository = this.cloningRepositoriesStore.repositories.find( r => r.url === url && r.path === path )! @@ -5035,15 +5016,12 @@ export class AppStore extends TypedBaseStore { repository: Repository, refspec: string ): Promise { - return this.withAuthenticatingUser( - repository, - async (repository, account) => { - const gitStore = this.gitStoreCache.get(repository) - await gitStore.fetchRefspec(account, refspec) + return this.withRefreshedGitHubRepository(repository, async repository => { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.fetchRefspec(refspec) - return this._refreshRepository(repository) - } - ) + return this._refreshRepository(repository) + }) } /** @@ -5055,8 +5033,8 @@ export class AppStore extends TypedBaseStore { * if _any_ fetches or pulls are currently in-progress. */ public _fetch(repository: Repository, fetchType: FetchType): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performFetch(repository, account, fetchType) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performFetch(repository, fetchType) }) } @@ -5071,8 +5049,8 @@ export class AppStore extends TypedBaseStore { remote: IRemote, fetchType: FetchType ): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performFetch(repository, account, fetchType, [remote]) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performFetch(repository, fetchType, [remote]) }) } @@ -5085,7 +5063,6 @@ export class AppStore extends TypedBaseStore { */ private async performFetch( repository: Repository, - account: IGitAccount | null, fetchType: FetchType, remotes?: IRemote[] ): Promise { @@ -5105,10 +5082,9 @@ export class AppStore extends TypedBaseStore { } if (remotes === undefined) { - await gitStore.fetch(account, isBackgroundTask, progressCallback) + await gitStore.fetch(isBackgroundTask, progressCallback) } else { await gitStore.fetchRemotes( - account, remotes, isBackgroundTask, progressCallback @@ -6090,12 +6066,12 @@ export class AppStore extends TypedBaseStore { }` } - private async withAuthenticatingUser( + private async withRefreshedGitHubRepository( repository: Repository, - fn: (repository: Repository, account: IGitAccount | null) => Promise + fn: (repository: Repository) => Promise ): Promise { let updatedRepository = repository - let account: IGitAccount | null = getAccountForRepository( + const account: Account | null = getAccountForRepository( this.accounts, updatedRepository ) @@ -6108,30 +6084,9 @@ export class AppStore extends TypedBaseStore { updatedRepository = await this.repositoryWithRefreshedGitHubRepository( repository ) - account = getAccountForRepository(this.accounts, updatedRepository) - } - - if (!account) { - const gitStore = this.gitStoreCache.get(repository) - const remote = gitStore.currentRemote - if (remote) { - const hostname = getGenericHostname(remote.url) - const username = getGenericUsername(hostname) - if (username != null) { - account = { login: username, endpoint: hostname } - } - } - } - - if (account instanceof Account) { - const hasValidToken = - account.token.length > 0 ? 'has token' : 'empty token' - log.info( - `[AppStore.withAuthenticatingUser] account found for repository: ${repository.name} - ${account.login} (${hasValidToken})` - ) } - return fn(updatedRepository, account) + return fn(updatedRepository) } private updateRevertProgress( @@ -6152,14 +6107,14 @@ export class AppStore extends TypedBaseStore { repository: Repository, commit: Commit ): Promise { - return this.withAuthenticatingUser(repository, async (repo, account) => { - const gitStore = this.gitStoreCache.get(repo) + return this.withRefreshedGitHubRepository(repository, async repository => { + const gitStore = this.gitStoreCache.get(repository) - await gitStore.revertCommit(repo, commit, account, progress => { - this.updateRevertProgress(repo, progress) + await gitStore.revertCommit(repository, commit, progress => { + this.updateRevertProgress(repository, progress) }) - this.updateRevertProgress(repo, null) + this.updateRevertProgress(repository, null) await this._refreshRepository(repository) }) } @@ -6586,6 +6541,19 @@ export class AppStore extends TypedBaseStore { return Promise.resolve() } + /** + * Set the application-wide tab indentation + */ + public _setSelectedTabSize(tabSize: number) { + if (!isNaN(tabSize)) { + this.selectedTabSize = tabSize + setNumber(tabSizeKey, tabSize) + this.emitUpdate() + } + + return Promise.resolve() + } + public async _resolveCurrentEditor() { const match = await findEditorOrDefault(this.selectedExternalEditor) const resolvedExternalEditor = match != null ? match.editor : null @@ -7007,11 +6975,11 @@ export class AppStore extends TypedBaseStore { ): Promise { const gitStore = this.gitStoreCache.get(repository) - const checkoutSuccessful = await this.withAuthenticatingUser( + const checkoutSuccessful = await this.withRefreshedGitHubRepository( repository, - (r, account) => { + repository => { return gitStore.performFailableOperation(() => - checkoutBranch(repository, account, targetBranch) + checkoutBranch(repository, targetBranch, gitStore.currentRemote) ) } ) @@ -7122,9 +7090,9 @@ export class AppStore extends TypedBaseStore { } const gitStore = this.gitStoreCache.get(repository) - await this.withAuthenticatingUser(repository, async (r, account) => { + await this.withRefreshedGitHubRepository(repository, async repository => { await gitStore.performFailableOperation(() => - checkoutBranch(repository, account, sourceBranch) + checkoutBranch(repository, sourceBranch, gitStore.currentRemote) ) }) } diff --git a/app/src/lib/stores/commit-status-store.ts b/app/src/lib/stores/commit-status-store.ts index 6990edd9695..506e32f39cd 100644 --- a/app/src/lib/stores/commit-status-store.ts +++ b/app/src/lib/stores/commit-status-store.ts @@ -1,24 +1,24 @@ import pLimit from 'p-limit' import QuickLRU from 'quick-lru' +import { Disposable, DisposableLike } from 'event-kit' +import xor from 'lodash/xor' import { Account } from '../../models/account' -import { AccountsStore } from './accounts-store' import { GitHubRepository } from '../../models/github-repository' import { API, getAccountForEndpoint, IAPICheckSuite } from '../api' -import { DisposableLike, Disposable } from 'event-kit' import { - ICombinedRefCheck, - IRefCheck, - createCombinedCheckFromChecks, apiCheckRunToRefCheck, - getLatestCheckRunsByName, apiStatusToRefCheck, - getLatestPRWorkflowRunsLogsForCheckRun, + createCombinedCheckFromChecks, getCheckRunActionsWorkflowRuns, + getLatestCheckRunsById, + getLatestPRWorkflowRunsLogsForCheckRun, + ICombinedRefCheck, + IRefCheck, manuallySetChecksToPending, } from '../ci-checks/ci-checks' -import xor from 'lodash/xor' import { offsetFromNow } from '../offset-from' +import { AccountsStore } from './accounts-store' interface ICommitStatusCacheEntry { /** @@ -310,9 +310,7 @@ export class CommitStatusStore { } if (checkRuns !== null) { - const latestCheckRunsByName = getLatestCheckRunsByName( - checkRuns.check_runs - ) + const latestCheckRunsByName = getLatestCheckRunsById(checkRuns.check_runs) checks.push(...latestCheckRunsByName.map(apiCheckRunToRefCheck)) } diff --git a/app/src/lib/stores/git-store.ts b/app/src/lib/stores/git-store.ts index c3e5443be2b..2ad88655838 100644 --- a/app/src/lib/stores/git-store.ts +++ b/app/src/lib/stores/git-store.ts @@ -89,7 +89,6 @@ import { findDefaultRemote } from './helpers/find-default-remote' import { Author, isKnownAuthor } from '../../models/author' import { formatCommitMessage } from '../format-commit-message' import { GitAuthor } from '../../models/git-author' -import { IGitAccount } from '../../models/git-account' import { BaseStore } from './base-store' import { getStashes, getStashedFiles } from '../git/stash' import { IStashEntry, StashedChangesLoadStates } from '../../models/stash-entry' @@ -145,6 +144,8 @@ export class GitStore extends BaseStore { private _tagsToPush: ReadonlyArray = [] + private _remotes: ReadonlyArray = [] + private _defaultRemote: IRemote | null = null private _currentRemote: IRemote | null = null @@ -948,7 +949,6 @@ export class GitStore extends BaseStore { * the overall fetch progress. */ public async fetch( - account: IGitAccount | null, backgroundTask: boolean, progressCallback?: (fetchProgress: IFetchProgress) => void ): Promise { @@ -974,7 +974,6 @@ export class GitStore extends BaseStore { if (remotes.size > 0) { await this.fetchRemotes( - account, [...remotes.values()], backgroundTask, progressCallback @@ -1014,7 +1013,6 @@ export class GitStore extends BaseStore { * the overall fetch progress. */ public async fetchRemotes( - account: IGitAccount | null, remotes: ReadonlyArray, backgroundTask: boolean, progressCallback?: (fetchProgress: IFetchProgress) => void @@ -1029,7 +1027,7 @@ export class GitStore extends BaseStore { const remote = remotes[i] const startProgressValue = i * weight - await this.fetchRemote(account, remote, backgroundTask, progress => { + await this.fetchRemote(remote, backgroundTask, progress => { if (progress && progressCallback) { progressCallback({ ...progress, @@ -1050,7 +1048,6 @@ export class GitStore extends BaseStore { * the overall fetch progress. */ public async fetchRemote( - account: IGitAccount | null, remote: IRemote, backgroundTask: boolean, progressCallback?: (fetchProgress: IFetchProgress) => void @@ -1062,7 +1059,7 @@ export class GitStore extends BaseStore { } const fetchSucceeded = await this.performFailableOperation( async () => { - await fetchRepo(repo, account, remote, progressCallback, backgroundTask) + await fetchRepo(repo, remote, progressCallback, backgroundTask) return true }, { backgroundTask, retryAction } @@ -1077,7 +1074,7 @@ export class GitStore extends BaseStore { // Updating the local HEAD symref isn't critical so we don't want // to show an error message to the user and have them retry the // entire pull operation if it fails. - await updateRemoteHEAD(repo, account, remote, backgroundTask).catch(e => + await updateRemoteHEAD(repo, remote, backgroundTask).catch(e => log.error('Failed updating remote HEAD', e) ) } @@ -1091,16 +1088,13 @@ export class GitStore extends BaseStore { * part of this action. Refer to git-scm for more * information on refspecs: https://www.git-scm.com/book/tr/v2/Git-Internals-The-Refspec */ - public async fetchRefspec( - account: IGitAccount | null, - refspec: string - ): Promise { + public async fetchRefspec(refspec: string): Promise { // TODO: we should favour origin here const remotes = await getRemotes(this.repository) for (const remote of remotes) { await this.performFailableOperation(() => - fetchRefspec(this.repository, account, remote, refspec) + fetchRefspec(this.repository, remote, refspec) ) } } @@ -1267,6 +1261,7 @@ export class GitStore extends BaseStore { public async loadRemotes(): Promise { const remotes = await getRemotes(this.repository) + this._remotes = remotes this._defaultRemote = findDefaultRemote(remotes) const currentRemoteName = @@ -1368,6 +1363,11 @@ export class GitStore extends BaseStore { return this._aheadBehind } + /** The list of configured remotes for the repository */ + public get remotes() { + return this._remotes + } + /** * The remote considered to be the "default" remote in the repository. * @@ -1610,11 +1610,10 @@ export class GitStore extends BaseStore { public async revertCommit( repository: Repository, commit: Commit, - account: IGitAccount | null, progressCallback?: (fetchProgress: IRevertProgress) => void ): Promise { await this.performFailableOperation(() => - revertCommit(repository, commit, account, progressCallback) + revertCommit(repository, commit, this.currentRemote, progressCallback) ) this.emitUpdate() diff --git a/app/src/lib/stores/helpers/branch-pruner.ts b/app/src/lib/stores/helpers/branch-pruner.ts index e9ab9b64fc5..33b4b945a79 100644 --- a/app/src/lib/stores/helpers/branch-pruner.ts +++ b/app/src/lib/stores/helpers/branch-pruner.ts @@ -241,7 +241,9 @@ export class BranchPruner { log.info(`[BranchPruner] Branch '${branchName}' marked for deletion`) } } - this.onPruneCompleted(this.repository) + this.onPruneCompleted(this.repository).catch(e => { + log.error(`[BranchPruner] Error calling onPruneCompleted`, e) + }) } } diff --git a/app/src/lib/stores/helpers/create-tutorial-repository.ts b/app/src/lib/stores/helpers/create-tutorial-repository.ts index dea55ed2208..ceacd9e94f5 100644 --- a/app/src/lib/stores/helpers/create-tutorial-repository.ts +++ b/app/src/lib/stores/helpers/create-tutorial-repository.ts @@ -73,7 +73,7 @@ async function pushRepo( const pushOpts = await executionOptionsWithProgress( { - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), }, new PushProgressParser(), progress => { diff --git a/app/src/lib/stores/notifications-store.ts b/app/src/lib/stores/notifications-store.ts index 5d0b04f1152..60453ae270b 100644 --- a/app/src/lib/stores/notifications-store.ts +++ b/app/src/lib/stores/notifications-store.ts @@ -1,25 +1,34 @@ +import { NotificationCallback } from 'desktop-notifications/dist/notification-callback' +import { Commit, shortenSHA } from '../../models/commit' +import { GitHubRepository } from '../../models/github-repository' +import { PullRequest, getPullRequestCommitRef } from '../../models/pull-request' import { Repository, - isRepositoryWithGitHubRepository, RepositoryWithGitHubRepository, - isRepositoryWithForkedGitHubRepository, getForkContributionTarget, + isRepositoryWithForkedGitHubRepository, + isRepositoryWithGitHubRepository, } from '../../models/repository' import { ForkContributionTarget } from '../../models/workflow-preferences' -import { getPullRequestCommitRef, PullRequest } from '../../models/pull-request' +import { getVerbForPullRequestReview } from '../../ui/notifications/pull-request-review-helpers' import { API, APICheckConclusion, IAPIComment } from '../api' import { - createCombinedCheckFromChecks, - getLatestCheckRunsByName, - apiStatusToRefCheck, - apiCheckRunToRefCheck, IRefCheck, + apiCheckRunToRefCheck, + apiStatusToRefCheck, + createCombinedCheckFromChecks, + getLatestCheckRunsById, } from '../ci-checks/ci-checks' -import { AccountsStore } from './accounts-store' import { getCommit } from '../git' -import { GitHubRepository } from '../../models/github-repository' -import { PullRequestCoordinator } from './pull-request-coordinator' -import { Commit, shortenSHA } from '../../models/commit' +import { getBoolean, setBoolean } from '../local-storage' +import { showNotification } from '../notifications/show-notification' +import { StatsStore } from '../stats' +import { truncateWithEllipsis } from '../truncate-with-ellipsis' +import { + ValidNotificationPullRequestReview, + isValidNotificationPullRequestReview, +} from '../valid-notification-pull-request-review' +import { AccountsStore } from './accounts-store' import { AliveStore, DesktopAliveEvent, @@ -27,16 +36,7 @@ import { IDesktopPullRequestCommentAliveEvent, IDesktopPullRequestReviewSubmitAliveEvent, } from './alive-store' -import { setBoolean, getBoolean } from '../local-storage' -import { showNotification } from '../notifications/show-notification' -import { StatsStore } from '../stats' -import { truncateWithEllipsis } from '../truncate-with-ellipsis' -import { getVerbForPullRequestReview } from '../../ui/notifications/pull-request-review-helpers' -import { - isValidNotificationPullRequestReview, - ValidNotificationPullRequestReview, -} from '../valid-notification-pull-request-review' -import { NotificationCallback } from 'desktop-notifications/dist/notification-callback' +import { PullRequestCoordinator } from './pull-request-coordinator' export type OnChecksFailedCallback = ( repository: RepositoryWithGitHubRepository, @@ -537,9 +537,7 @@ export class NotificationsStore { } if (checkRuns !== null) { - const latestCheckRunsByName = getLatestCheckRunsByName( - checkRuns.check_runs - ) + const latestCheckRunsByName = getLatestCheckRunsById(checkRuns.check_runs) checks.push(...latestCheckRunsByName.map(apiCheckRunToRefCheck)) } diff --git a/app/src/lib/stores/sign-in-store.ts b/app/src/lib/stores/sign-in-store.ts index cd981ad7666..af495c43086 100644 --- a/app/src/lib/stores/sign-in-store.ts +++ b/app/src/lib/stores/sign-in-store.ts @@ -24,6 +24,7 @@ import { AuthenticationMode } from '../../lib/2fa' import { minimumSupportedEnterpriseVersion } from '../../lib/enterprise' import { TypedBaseStore } from './base-store' import { timeout } from '../promise' +import { isDotCom, isGHE } from '../endpoint-capabilities' 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.` @@ -242,7 +243,7 @@ export class SignInStore extends TypedBaseStore { } private async endpointSupportsBasicAuth(endpoint: string): Promise { - if (endpoint === getDotComAPIEndpoint()) { + if (isDotCom(endpoint) || isGHE(endpoint)) { return false } diff --git a/app/src/lib/trampoline/trampoline-askpass-handler.ts b/app/src/lib/trampoline/trampoline-askpass-handler.ts index 1ac68d31b7d..bb1ea9285d1 100644 --- a/app/src/lib/trampoline/trampoline-askpass-handler.ts +++ b/app/src/lib/trampoline/trampoline-askpass-handler.ts @@ -1,9 +1,8 @@ -import { getKeyForEndpoint } from '../auth' import { getSSHKeyPassphrase, keepSSHKeyPassphraseToStore, } from '../ssh/ssh-key-passphrase' -import { AccountsStore, TokenStore } from '../stores' +import { AccountsStore } from '../stores' import { ITrampolineCommand, TrampolineCommandHandler, @@ -17,7 +16,7 @@ import { import { removePendingSSHSecretToStore } from '../ssh/ssh-secret-storage' import { getHTMLURL } from '../api' import { - getGenericHostname, + getGenericPassword, getGenericUsername, setGenericPassword, setGenericUsername, @@ -30,6 +29,7 @@ import { setMostRecentGenericGitCredential, } from './trampoline-environment' import { IGitAccount } from '../../models/git-account' +import memoizeOne from 'memoize-one' async function handleSSHHostAuthenticity( prompt: string @@ -177,98 +177,135 @@ const handleAskPassUserPassword = async ( ) => { const info = (msg: string) => log.info(`askPassHandler: ${msg}`) const debug = (msg: string) => log.debug(`askPassHandler: ${msg}`) - const warn = (msg: string) => log.warn(`askPassHandler: ${msg}`) const { trampolineToken } = command - const hostname = getGenericHostname(remoteUrl) - const account = await findAccount(trampolineToken, accountsStore, hostname) + const parsedUrl = new URL(remoteUrl) + const endpoint = urlWithoutCredentials(remoteUrl) + const account = await findAccount(trampolineToken, accountsStore, remoteUrl) - if (!account) { - if (getHasRejectedCredentialsForEndpoint(trampolineToken, hostname)) { - debug(`not requesting credentials for ${hostname}`) - return undefined - } - - if (getIsBackgroundTaskEnvironment(trampolineToken)) { - debug('background task environment, skipping prompt') - return undefined - } + if (account) { + const accountKind = account instanceof Account ? 'account' : 'generic' + debug(`${accountKind} ${kind.toLowerCase()} for ${remoteUrl} found`) + return kind === 'Username' ? account.login : account.token + } - info(`no account found for ${hostname}`) + if (getHasRejectedCredentialsForEndpoint(trampolineToken, endpoint)) { + debug(`not requesting credentials for ${remoteUrl}`) + return undefined + } - if (hostname === 'github.com') { - // We don't want to show a generic auth prompt for GitHub.com and we - // don't have a good way to turn the sign in flow into a promise. More - // specifically we can create a promise that resolves when the GH sign in - // flow completes but we don't have a way to have the promise reject if - // the user cancels. - return undefined - } + if (getIsBackgroundTaskEnvironment(trampolineToken)) { + debug('background task environment, skipping prompt') + return undefined + } - const { username, password } = - await trampolineUIHelper.promptForGenericGitAuthentication(hostname) + info(`no account found for ${remoteUrl}`) - if (username.length > 0 && password.length > 0) { - setGenericUsername(hostname, username) - await setGenericPassword(hostname, username, password) + if (parsedUrl.hostname === 'github.com') { + // We don't want to show a generic auth prompt for GitHub.com and we + // don't have a good way to turn the sign in flow into a promise. More + // specifically we can create a promise that resolves when the GH sign in + // flow completes but we don't have a way to have the promise reject if + // the user cancels. + return undefined + } - info(`acquired generic credentials for ${hostname}`) + const { username, password } = + await trampolineUIHelper.promptForGenericGitAuthentication( + remoteUrl, + parsedUrl.username === '' ? undefined : parsedUrl.username + ) - return kind === 'Username' ? username : password - } else { - info('user cancelled generic git authentication') - setHasRejectedCredentialsForEndpoint(trampolineToken, hostname) - } + if (!username || !password) { + info('user cancelled generic git authentication') + setHasRejectedCredentialsForEndpoint(trampolineToken, endpoint) return undefined - } else { - const accountKind = account instanceof Account ? 'account' : 'generic' - if (kind === 'Username') { - debug(`${accountKind} username for ${hostname} found`) - return account.login - } else if (kind === 'Password') { - const token = - account instanceof Account && account.token.length > 0 - ? account.token - : await TokenStore.getItem( - getKeyForEndpoint(account.endpoint), - account.login - ) - - if (token) { - debug(`${accountKind} password for ${hostname} found`) - } else { - // We have a username but no password, that warrants a warning - warn(`${accountKind} password for ${hostname} missing`) - } - - return token ?? undefined - } } - return undefined + // Git will ordinarily prompt us twice, first for the username and then + // for the password. For the second prompt the url will contain the + // username. For example: + // Prompt 1: Username for 'https://example.com': + // < user enters username > + // Prompt 2: Password for 'https://username@example.com': + // + // So when we get a prompt that doesn't include the username we know that + // it's the first prompt. This matters because users can include the + // username in the remote url in which case Git won't even prompt us for + // the username. For example: + // https://username@dev.azure.com/org/repo/_git/repo + // + // If we're getting prompted for password directly with the username we + // don't want to store the username association, only the password. + if (parsedUrl.username === '') { + setGenericUsername(endpoint, username) + } + + await setGenericPassword(endpoint, username, password) + + info(`acquired generic credentials for ${remoteUrl}`) + + return kind === 'Username' ? username : password } +/** + * When we're asked for credentials we're typically first asked for the username + * immediately followed by the password. We memoize the getGenericPassword call + * such that we only call it once per endpoint/login pair. Since we include the + * trampoline token in the invalidation key we'll only call it once per + * trampoline session. + */ +const memoizedGetGenericPassword = memoizeOne( + (_trampolineToken: string, endpoint: string, login: string) => + getGenericPassword(endpoint, login) +) + async function findAccount( trampolineToken: string, accountsStore: AccountsStore, - hostname: string + remoteUrl: string ): Promise { const accounts = await accountsStore.getAll() + const parsedUrl = new URL(remoteUrl) + const endpoint = urlWithoutCredentials(remoteUrl) const account = accounts.find( - a => new URL(getHTMLURL(a.endpoint)).hostname === hostname + a => new URL(getHTMLURL(a.endpoint)).origin === parsedUrl.origin ) if (account) { return account } - const login = getGenericUsername(hostname) + const login = + parsedUrl.username === '' + ? getGenericUsername(endpoint) + : parsedUrl.username - if (hostname && login) { - setMostRecentGenericGitCredential(trampolineToken, hostname, login) - return { login, endpoint: hostname } + if (!login) { + return undefined } - return undefined + const token = await memoizedGetGenericPassword( + trampolineToken, + endpoint, + login + ) + + if (!token) { + // We have a username but no password, that warrants a warning + log.warn(`askPassHandler: generic password for ${remoteUrl} missing`) + return undefined + } + + setMostRecentGenericGitCredential(trampolineToken, endpoint, login) + + return { login, endpoint, token } +} + +function urlWithoutCredentials(remoteUrl: string): string { + const url = new URL(remoteUrl) + url.username = '' + url.password = '' + return url.toString() } diff --git a/app/src/lib/trampoline/trampoline-environment.ts b/app/src/lib/trampoline/trampoline-environment.ts index 64b0dc1ab0f..e3cc272b9c2 100644 --- a/app/src/lib/trampoline/trampoline-environment.ts +++ b/app/src/lib/trampoline/trampoline-environment.ts @@ -11,11 +11,13 @@ import { import { GitError as DugiteError, GitProcess } from 'dugite' import memoizeOne from 'memoize-one' import { enableCustomGitUserAgent } from '../feature-flag' -import { IGitAccount } from '../../models/git-account' import { GitError } from '../git/core' import { deleteGenericCredential } from '../generic-git-auth' -const mostRecentGenericGitCredential = new Map() +const mostRecentGenericGitCredential = new Map< + string, + { endpoint: string; login: string } +>() export const setMostRecentGenericGitCredential = ( trampolineToken: string, diff --git a/app/src/lib/trampoline/trampoline-ui-helper.ts b/app/src/lib/trampoline/trampoline-ui-helper.ts index 4fce45718d6..6bf17919ccc 100644 --- a/app/src/lib/trampoline/trampoline-ui-helper.ts +++ b/app/src/lib/trampoline/trampoline-ui-helper.ts @@ -59,12 +59,14 @@ class TrampolineUIHelper { } public promptForGenericGitAuthentication( - hostname: string + remoteUrl: string, + username?: string ): Promise<{ username: string; password: string }> { return new Promise(resolve => { this.dispatcher.showPopup({ type: PopupType.GenericGitAuthentication, - hostname, + remoteUrl, + username, onSubmit: (username: string, password: string) => resolve({ username, password }), onDismiss: () => resolve({ username: '', password: '' }), diff --git a/app/src/locales/json/lang_de.json b/app/src/locales/json/lang_de.json index 363d3856850..3fb1d6e828c 100644 --- a/app/src/locales/json/lang_de.json +++ b/app/src/locales/json/lang_de.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_en.json b/app/src/locales/json/lang_en.json index 28da9843527..03f3d086c78 100644 --- a/app/src/locales/json/lang_en.json +++ b/app/src/locales/json/lang_en.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_es.json b/app/src/locales/json/lang_es.json index 189963adf11..ae42c4a9a0c 100644 --- a/app/src/locales/json/lang_es.json +++ b/app/src/locales/json/lang_es.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_fr.json b/app/src/locales/json/lang_fr.json index 142d8340831..03f96c57eab 100644 --- a/app/src/locales/json/lang_fr.json +++ b/app/src/locales/json/lang_fr.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_it.json b/app/src/locales/json/lang_it.json index 9d139573f96..46902185af0 100644 --- a/app/src/locales/json/lang_it.json +++ b/app/src/locales/json/lang_it.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_ja.json b/app/src/locales/json/lang_ja.json index 3a900c12297..65ad58f70c6 100644 --- a/app/src/locales/json/lang_ja.json +++ b/app/src/locales/json/lang_ja.json @@ -422,6 +422,7 @@ "local-path-darwin": "ローカルパス", "multiple-commits": "個のコミット", "name": "名前", + "no": "いいえ", "ok": "Ok", "one-commit": "個のコミット", "one-or-less-commit": "個のコミット", @@ -459,6 +460,8 @@ "username-or-email": "ユーザー名 または メールアドレス", "view-conflicts": "コンフリクトの表示", "view-on-github": "GitHub で確認", + "warning": "警告", + "yes": "はい", "your-email": "あなたのメールアドレス@example.com", "your-name": "あなたの名前" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "ブランチはリモートにも存在しますが、そちらも削除しますか?", "confirm-delete-branch-1": "ブランチ ", "confirm-delete-branch-2": " を削除しますか?", - "confirm-delete-branch-3": "この操作は元に戻せません。", "delete-branch": "ブランチの削除", "delete-branch-darwin": "ブランチの削除", "yes-delete-this-branch": "はい、リモート上のこのブランチを削除します。" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": " を参照してください。", "integration-docs": "統合ドキュメント", "password": "パスワード", - "username": "ユーザー名", "we-were-unable-to-authenticate-1": " ", - "we-were-unable-to-authenticate-2": " で認証できませんでした。ユーザー名とパスワードを入力して、もう一度やり直してください。" + "we-were-unable-to-authenticate-2": " が認証できませんでした。 ", + "we-were-unable-to-authenticate-3": " ", + "we-were-unable-to-authenticate-4": " のパスワードを入力して", + "we-were-unable-to-authenticate-5": "ユーザー名とパスワードを入力して", + "we-were-unable-to-authenticate-6": "、もう一度やり直してください。" }, "git": { "default-branch-name-for-new-repositories": "新しいリポジトリのデフォルトのブランチ名", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "このチュートリアルを使用して、Git、GitHub、および GitHub Desktop の操作に慣れてください。", "welcome": "GitHub Desktop にようこそ" }, + "whitespace-hint-popover": { + "selecting-lines": "行の選択は、空白の変更を非表示にすると無効になります。", + "show-whitespace-changes": "空白の変更を表示しますか?" + }, "workflow-push-rejected": { "continue-in-browser": "ブラウザーで続行", "continue-in-browser-darwin": "ブラウザーで続行", diff --git a/app/src/locales/json/lang_ko.json b/app/src/locales/json/lang_ko.json index cf1dbbe0ad0..f578810b375 100644 --- a/app/src/locales/json/lang_ko.json +++ b/app/src/locales/json/lang_ko.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_nl.json b/app/src/locales/json/lang_nl.json index 9d139573f96..46902185af0 100644 --- a/app/src/locales/json/lang_nl.json +++ b/app/src/locales/json/lang_nl.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_pt-br.json b/app/src/locales/json/lang_pt-br.json index 5dfbfa91bc3..b67fbab44b0 100644 --- a/app/src/locales/json/lang_pt-br.json +++ b/app/src/locales/json/lang_pt-br.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_pt.json b/app/src/locales/json/lang_pt.json index 5dfbfa91bc3..b67fbab44b0 100644 --- a/app/src/locales/json/lang_pt.json +++ b/app/src/locales/json/lang_pt.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_ro.json b/app/src/locales/json/lang_ro.json index 9d139573f96..46902185af0 100644 --- a/app/src/locales/json/lang_ro.json +++ b/app/src/locales/json/lang_ro.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_ru.json b/app/src/locales/json/lang_ru.json index 285edeaa2a0..6b1a8dca9c7 100644 --- a/app/src/locales/json/lang_ru.json +++ b/app/src/locales/json/lang_ru.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_sv.json b/app/src/locales/json/lang_sv.json index 9d139573f96..46902185af0 100644 --- a/app/src/locales/json/lang_sv.json +++ b/app/src/locales/json/lang_sv.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_zh-hant.json b/app/src/locales/json/lang_zh-hant.json index a1c959115e2..c5df4cde010 100644 --- a/app/src/locales/json/lang_zh-hant.json +++ b/app/src/locales/json/lang_zh-hant.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/locales/json/lang_zh.json b/app/src/locales/json/lang_zh.json index 97ee3ddca83..23d2b155738 100644 --- a/app/src/locales/json/lang_zh.json +++ b/app/src/locales/json/lang_zh.json @@ -422,6 +422,7 @@ "local-path-darwin": "Local Path", "multiple-commits": "commits", "name": "Name", + "no": "No", "ok": "Ok", "one-commit": "commit", "one-or-less-commit": "commit", @@ -459,6 +460,8 @@ "username-or-email": "Username or email address", "view-conflicts": "View conflicts", "view-on-github": "View on GitHub", + "warning": "Warning", + "yes": "Yes", "your-email": "common.your-email@example.com", "your-name": "Your Name" }, @@ -653,7 +656,6 @@ "branch-also-exists-on-the-remote": "The branch also exists on the remote, do you wish to delete it\n there as well?", "confirm-delete-branch-1": "Delete branch ", "confirm-delete-branch-2": "?", - "confirm-delete-branch-3": "This action cannot be undone.", "delete-branch": "Delete branch", "delete-branch-darwin": "Delete Branch", "yes-delete-this-branch": "Yes, delete this branch on the remote" @@ -790,9 +792,12 @@ "depending-on-your-repository-s-2": ".", "integration-docs": "integration docs", "password": "Password", - "username": "Username", "we-were-unable-to-authenticate-1": "We were unable to authenticate with ", - "we-were-unable-to-authenticate-2": ". Please enter your username and password to try again." + "we-were-unable-to-authenticate-2": ". Please enter ", + "we-were-unable-to-authenticate-3": "the password for the user ", + "we-were-unable-to-authenticate-4": " ", + "we-were-unable-to-authenticate-5": "your username and password", + "we-were-unable-to-authenticate-6": "to try again." }, "git": { "default-branch-name-for-new-repositories": "Default branch name for new repositories", @@ -1756,6 +1761,10 @@ "use-this-tutorial": "Use this tutorial to get comfortable with Git, GitHub, and GitHub\n Desktop.", "welcome": "Welcome to GitHub Desktop" }, + "whitespace-hint-popover": { + "selecting-lines": "Selecting lines is disabled when hiding whitespace changes.", + "show-whitespace-changes": "Show whitespace changes?" + }, "workflow-push-rejected": { "continue-in-browser": "Continue in browser", "continue-in-browser-darwin": "Continue in Browser", diff --git a/app/src/main-process/app-window.ts b/app/src/main-process/app-window.ts index e321e5662ec..b4d138cae44 100644 --- a/app/src/main-process/app-window.ts +++ b/app/src/main-process/app-window.ts @@ -6,6 +6,7 @@ import { autoUpdater, nativeTheme, } from 'electron' +import { shell } from '../lib/app-shell' import { Emitter, Disposable } from 'event-kit' import { encodePathAsUrl } from '../lib/path' import { @@ -324,6 +325,32 @@ export class AppWindow { } } + /** Handle when a modal dialog is opened. */ + public dialogDidOpen() { + if (this.window.isFocused()) { + // No additional notifications are needed. + return + } + // Care is taken to mimic OS dialog behaviors. + if (__DARWIN__) { + // macOS beeps when a modal dialog is opened. + shell.beep() + // See https://developer.apple.com/documentation/appkit/nsapplication/1428358-requestuserattention + // "If the inactive app presents a modal panel, this method will be invoked with NSCriticalRequest + // automatically. The modal panel is not brought to the front for an inactive app." + // NOTE: flashFrame() uses the 'informational' level, so we need to explicitly bounce the dock + // with the 'critical' level in order to that described behavior. + app.dock.bounce('critical') + } else { + // See https://learn.microsoft.com/en-us/windows/win32/uxguide/winenv-taskbar#taskbar-button-flashing + // "If an inactive program requires immediate attention, + // flash its taskbar button to draw attention and leave it highlighted." + // It advises not to beep. + this.window.once('focus', () => this.window.flashFrame(false)) + this.window.flashFrame(true) + } + } + /** Send a certificate error to the renderer. */ public sendCertificateError( certificate: Electron.Certificate, diff --git a/app/src/main-process/main.ts b/app/src/main-process/main.ts index 460faa19b1b..bd85f091465 100644 --- a/app/src/main-process/main.ts +++ b/app/src/main-process/main.ts @@ -105,8 +105,6 @@ const protocolLauncherArg = '--protocol-launcher' const possibleProtocols = new Set(['x-github-client']) if (__DEV__) { possibleProtocols.add('x-github-desktop-dev-auth') -} else if (__LINUX__) { - possibleProtocols.add('x-github-desktop-auth') } else { //possibleProtocols.add('x-github-desktop-auth') possibleProtocols.add('x-github-desktop-dev-auth') /** if tesing, beta */ @@ -635,6 +633,9 @@ app.on('ready', () => { mainWindow?.selectAllWindowContents() ) + /** An event sent by the renderer indicating a modal dialog is opened */ + ipcMain.on('dialog-did-open', () => mainWindow?.dialogDidOpen()) + /** * An event sent by the renderer asking whether the Desktop is in the * applications folder diff --git a/app/src/models/clone-options.ts b/app/src/models/clone-options.ts index b50cccf4e8c..2138191b35b 100644 --- a/app/src/models/clone-options.ts +++ b/app/src/models/clone-options.ts @@ -1,9 +1,5 @@ -import { IGitAccount } from './git-account' - /** Additional arguments to provide when cloning a repository */ export type CloneOptions = { - /** The optional identity to provide when cloning. */ - readonly account: IGitAccount | null /** The branch to checkout after the clone has completed. */ readonly branch?: string /** The default branch name in case we're cloning an empty repository. */ diff --git a/app/src/models/git-account.ts b/app/src/models/git-account.ts index 0f5e04918c7..6d9bdcca8a8 100644 --- a/app/src/models/git-account.ts +++ b/app/src/models/git-account.ts @@ -7,4 +7,7 @@ export interface IGitAccount { /** The endpoint with which the user is authenticating. */ readonly endpoint: string + + /** The token/password to authenticate with */ + readonly token: string } diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index b5691513128..435b6da628f 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -171,7 +171,8 @@ export type PopupDetail = | { type: PopupType.CLIInstalled } | { type: PopupType.GenericGitAuthentication - hostname: string + remoteUrl: string + username?: string onSubmit: (username: string, password: string) => void onDismiss: () => void } diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 3a6fbf86f4a..9321fbd9fe5 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -1724,6 +1724,7 @@ export class App extends React.Component { onDismissed={onPopupDismissedFn} selectedShell={this.state.selectedShell} selectedTheme={this.state.selectedTheme} + selectedTabSize={this.state.selectedTabSize} repositoryIndicatorsEnabled={this.state.repositoryIndicatorsEnabled} onOpenFileInExternalEditor={this.openFileInExternalEditor} underlineLinks={this.state.underlineLinks} @@ -1926,7 +1927,8 @@ export class App extends React.Component { return ( { ? ApplicationTheme.Light : this.state.currentTheme + const currentTabSize = this.state.selectedTabSize + return ( -
+
{this.renderTitlebar()} {this.state.showWelcomeFlow diff --git a/app/src/ui/delete-branch/delete-branch-dialog.tsx b/app/src/ui/delete-branch/delete-branch-dialog.tsx index c74ab68ec99..5b4591c70f3 100644 --- a/app/src/ui/delete-branch/delete-branch-dialog.tsx +++ b/app/src/ui/delete-branch/delete-branch-dialog.tsx @@ -62,11 +62,6 @@ export class DeleteBranch extends React.Component< )} {this.props.branch.name} {t('delete-branch-dialog.confirm-delete-branch-2', '?')} -
- {t( - 'delete-branch-dialog.confirm-delete-branch-3', - 'This action cannot be undone.' - )}

{this.renderDeleteOnRemote()}
diff --git a/app/src/ui/dialog/dialog.tsx b/app/src/ui/dialog/dialog.tsx index 6c6e7d38c20..350ecfcd8aa 100644 --- a/app/src/ui/dialog/dialog.tsx +++ b/app/src/ui/dialog/dialog.tsx @@ -5,6 +5,7 @@ import { createUniqueId, releaseUniqueId } from '../lib/id-pool' import { getTitleBarHeight } from '../window/title-bar' import { isTopMostDialog } from './is-top-most' import { isMacOSSonoma, isMacOSVentura } from '../../lib/get-os' +import { sendDialogDidOpen } from '../main-process-proxy' /** * Class name used for elements that should be focused initially when a dialog @@ -366,6 +367,7 @@ export class Dialog extends React.Component { } public componentDidMount() { + sendDialogDidOpen() this.checkIsTopMostDialog(this.context.isTopMost) } diff --git a/app/src/ui/diff/whitespace-hint-popover.tsx b/app/src/ui/diff/whitespace-hint-popover.tsx index 220ad3b004e..5a502bf50a4 100644 --- a/app/src/ui/diff/whitespace-hint-popover.tsx +++ b/app/src/ui/diff/whitespace-hint-popover.tsx @@ -6,6 +6,7 @@ import { PopoverDecoration, } from '../lib/popover' import { OkCancelButtonGroup } from '../dialog' +import { t } from 'i18next' interface IWhitespaceHintPopoverProps { readonly anchor: HTMLElement | null @@ -27,14 +28,22 @@ export class WhitespaceHintPopover extends React.Component -

Show whitespace changes?

+

+ {t( + 'whitespace-hint-popover.show-whitespace-changes', + 'Show whitespace changes?' + )} +

- Selecting lines is disabled when hiding whitespace changes. + {t( + 'whitespace-hint-popover.selecting-lines', + 'Selecting lines is disabled when hiding whitespace changes.' + )}