From 437f531d10c93185efe1f2c250eed2981eb29712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Schr=C3=B6der?= Date: Mon, 30 Jun 2025 11:05:36 +0000 Subject: [PATCH 001/865] Activate the Copilot button based on the available accounts and the generation function for commit messages --- app/src/ui/changes/commit-message.tsx | 28 ++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index a515a6a8833..74c99aca286 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -871,9 +871,11 @@ export class CommitMessage extends React.Component< } private renderCopilotButton() { + if (!this.isCopilotButtonEnabled) { + return null + } + const { - accounts, - onGenerateCommitMessage, filesSelected, isCommitting, isGeneratingCommitMessage, @@ -881,13 +883,6 @@ export class CommitMessage extends React.Component< shouldShowGenerateCommitMessageCallOut, } = this.props - if ( - !accounts.some(enableCommitMessageGeneration) || - onGenerateCommitMessage === undefined - ) { - return null - } - const noFilesSelected = filesSelected.length === 0 const noChangesAvailable = !commitToAmend && noFilesSelected @@ -997,15 +992,26 @@ export class CommitMessage extends React.Component< } } + /** + * Whether the Copilot button should be available + */ + private get isCopilotButtonEnabled() { + const { accounts, onGenerateCommitMessage } = this.props + return ( + accounts.some(enableCommitMessageGeneration) && + onGenerateCommitMessage !== undefined + ) + } + /** * Whether or not there's anything to render in the action bar */ private get isActionBarEnabled() { - return this.isCoAuthorInputEnabled + return this.isCoAuthorInputEnabled || this.isCopilotButtonEnabled } private renderActionBar() { - if (!this.isCoAuthorInputEnabled) { + if (!this.isActionBarEnabled) { return null } From d2298b978b23700d3d88b6d443358b1eaea48c61 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 4 Jul 2025 12:11:25 +0200 Subject: [PATCH 002/865] Never follow redirects when trying to get the meta endpoint --- app/src/lib/api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index e661f4ccd61..d657e228ca5 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -2473,6 +2473,7 @@ export async function isGitHubHost(url: string) { signal: ac.signal, credentials: 'omit', method: 'HEAD', + redirect: 'error', }) tryUpdateEndpointVersionFromResponse(endpoint, response) From 072980c30cb9c68a0454bc88afbed79d095dc863 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 4 Jul 2025 12:12:22 +0200 Subject: [PATCH 003/865] Preserve port, strip search and hash, and enforce https when resolving enterprise api url --- app/src/lib/api.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index d657e228ca5..acfeee1bac0 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -2314,17 +2314,9 @@ export function getHTMLURL(endpoint: string): string { * http://github.mycompany.com -> http://github.mycompany.com/api/v3 */ export function getEnterpriseAPIURL(endpoint: string): string { - if (isGHE(endpoint)) { - const url = new window.URL(endpoint) + const { host } = new window.URL(endpoint) - url.pathname = '/' - url.hostname = `api.${url.hostname}` - - return url.toString() - } - - const parsed = URL.parse(endpoint) - return `${parsed.protocol}//${parsed.hostname}/api/v3` + return isGHE(endpoint) ? `https://api.${host}/` : `https://${host}/api/v3` } export const getAPIEndpoint = (endpoint: string) => From cc71d52591073abef85da046f07785d5043b0d45 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 4 Jul 2025 12:12:28 +0200 Subject: [PATCH 004/865] :book: --- app/src/lib/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index acfeee1bac0..7277991cf69 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -2311,7 +2311,7 @@ export function getHTMLURL(endpoint: string): string { /** * Get the API URL for an HTML URL. For example: * - * http://github.mycompany.com -> http://github.mycompany.com/api/v3 + * http://github.mycompany.com -> https://github.mycompany.com/api/v3 */ export function getEnterpriseAPIURL(endpoint: string): string { const { host } = new window.URL(endpoint) From 08fe8fdb2eac3bcd2bf67c4f2f17ef8b84cda962 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 4 Jul 2025 12:12:41 +0200 Subject: [PATCH 005/865] Assume generic host unless https --- app/src/lib/trampoline/trampoline-credential-helper.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/lib/trampoline/trampoline-credential-helper.ts b/app/src/lib/trampoline/trampoline-credential-helper.ts index 2e2d4549a3f..b4fd1562700 100644 --- a/app/src/lib/trampoline/trampoline-credential-helper.ts +++ b/app/src/lib/trampoline/trampoline-credential-helper.ts @@ -169,6 +169,12 @@ const getEndpointKind = async (cred: Credential, store: Store) => { return isDotCom(existingAccount.endpoint) ? 'github.com' : 'enterprise' } + // All GitHub hosts use HTTPS, so if the protocol is not HTTPS we can + // assume that this is not a GitHub host. + if (credentialUrl.protocol !== 'https:') { + return 'generic' + } + return (await isGitHubHost(endpoint)) ? 'enterprise' : 'generic' } From 6f7b6c0f90e662ab2186d7c5f8c008d6458b6061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20Schro=CC=88der=20=F0=9F=9A=80?= Date: Mon, 7 Jul 2025 11:11:17 +0200 Subject: [PATCH 006/865] Show separator only when co-author input is enabled The separator div is now conditionally rendered based on whether the co-author input is enabled, improving UI clarity. --- app/src/ui/changes/commit-message.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index 74c99aca286..e6848f65ad5 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -894,7 +894,7 @@ export class CommitMessage extends React.Component< return ( <> -
+ {this.isCoAuthorInputEnabled &&
}
} diff --git a/app/src/ui/lib/list/section-list.tsx b/app/src/ui/lib/list/section-list.tsx index e8cf95e1340..1313499a1f9 100644 --- a/app/src/ui/lib/list/section-list.tsx +++ b/app/src/ui/lib/list/section-list.tsx @@ -67,6 +67,18 @@ interface ISectionListProps { */ readonly rowRenderer: (indexPath: RowIndexPath) => JSX.Element | null + /** + * Optional render function for the keyboard focus tooltip + * + * This is used to render a tooltip when the row is focused via keyboard + * navigation. This should be provided if the row has tooltip content that is + * only accessible via the mouse. The content in the mouse tooltip(s) will + * need to be in the keyboard focus tooltip as well. + */ + readonly renderKeyboardFocusTooltip?: ( + indexPath: RowIndexPath + ) => JSX.Element | string | null + /** * Whether or not a given section has a header row at the beginning. When * ommitted, it's assumed the section does NOT have a header row. @@ -1221,6 +1233,7 @@ export class SectionList extends React.Component< children={element} selectable={selectable} className={customClasses} + renderKeyboardFocusTooltip={this.props.renderKeyboardFocusTooltip} /> ) } diff --git a/app/src/ui/lib/section-filter-list.tsx b/app/src/ui/lib/section-filter-list.tsx index a2a32e4c1da..f4d735915c9 100644 --- a/app/src/ui/lib/section-filter-list.tsx +++ b/app/src/ui/lib/section-filter-list.tsx @@ -60,6 +60,16 @@ interface ISectionFilterListProps { /** Called to render each visible item. */ readonly renderItem: (item: T, matches: IMatches) => JSX.Element | null + /** + * Optional render function for the keyboard focus tooltip + * + * This is used to render a tooltip when the row is focused via keyboard + * navigation. This should be provided if the row has tooltip content that is + * only accessible via the mouse. The content in the mouse tooltip(s) will + * need to be in the keyboard focus tooltip as well. + */ + readonly renderKeyboardFocusTooltip?: (item: T) => JSX.Element | string | null + /** Called to render header for the group with the given identifier. */ readonly renderGroupHeader?: ( identifier: GroupIdentifier @@ -371,6 +381,7 @@ export class SectionFilterList< ref={this.onListRef} rowCount={this.state.rows.map(r => r.length)} rowRenderer={this.renderRow} + renderKeyboardFocusTooltip={this.renderKeyboardFocusTooltip} sectionHasHeader={this.sectionHasHeader} getRowAriaLabel={this.getRowAriaLabel} getSectionAriaLabel={this.getSectionAriaLabel} @@ -439,6 +450,16 @@ export class SectionFilterList< } } + private renderKeyboardFocusTooltip = ( + index: RowIndexPath + ): JSX.Element | string | null => { + const row = this.state.rows[index.section][index.row] + if (row.kind !== 'item' || !this.props.renderKeyboardFocusTooltip) { + return null + } + return this.props.renderKeyboardFocusTooltip(row.item) + } + private onTextBoxRef = (component: TextBox | null) => { this.filterTextBox = component } diff --git a/app/src/ui/lib/tooltip.tsx b/app/src/ui/lib/tooltip.tsx index 956b1d9ab2b..6c49ed92fcb 100644 --- a/app/src/ui/lib/tooltip.tsx +++ b/app/src/ui/lib/tooltip.tsx @@ -157,6 +157,8 @@ export interface ITooltipProps { * Default: true * */ readonly applyAriaDescribedBy?: boolean + + readonly onlyShowOnKeyboardFocus?: boolean } interface ITooltipState { @@ -398,10 +400,13 @@ export class Tooltip extends React.Component< } private installTooltip(elem: TooltipTarget) { - elem.addEventListener('mouseenter', this.onTargetMouseEnter) - elem.addEventListener('mouseleave', this.onTargetMouseLeave) - elem.addEventListener('mousemove', this.onTargetMouseMove) - elem.addEventListener('mousedown', this.onTargetMouseDown) + if (this.props.onlyShowOnKeyboardFocus !== true) { + elem.addEventListener('mouseenter', this.onTargetMouseEnter) + elem.addEventListener('mouseleave', this.onTargetMouseLeave) + elem.addEventListener('mousemove', this.onTargetMouseMove) + elem.addEventListener('mousedown', this.onTargetMouseDown) + } + elem.addEventListener('focus', this.onTargetFocus) elem.addEventListener('focusin', this.onTargetFocusIn) elem.addEventListener('focusout', this.onTargetBlur) @@ -464,6 +469,8 @@ export class Tooltip extends React.Component< ) { this.beginShowTooltip() } + + console.log('onTargetFocus', this.props.target.current) } private onTargetClick = (event: FocusEvent) => { @@ -574,7 +581,9 @@ export class Tooltip extends React.Component< const { direction, tooltipOffset } = this.props return offsetRect( - direction === undefined ? this.mouseRect : target.getBoundingClientRect(), + direction === undefined && this.props.onlyShowOnKeyboardFocus !== true + ? this.mouseRect + : target.getBoundingClientRect(), tooltipOffset?.x ?? 0, tooltipOffset?.y ?? 0 ) diff --git a/app/src/ui/repositories-list/repositories-list.tsx b/app/src/ui/repositories-list/repositories-list.tsx index d5e6a10273b..aec68ce9eba 100644 --- a/app/src/ui/repositories-list/repositories-list.tsx +++ b/app/src/ui/repositories-list/repositories-list.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { RepositoryListItem } from './repository-list-item' +import { commitGrammar, RepositoryListItem } from './repository-list-item' import { groupRepositories, IRepositoryListItem, @@ -26,6 +26,7 @@ import { generateRepositoryListContextMenu } from '../repositories-list/reposito import { SectionFilterList } from '../lib/section-filter-list' import { assertNever } from '../../lib/fatal-error' import { enableMultipleEnterpriseAccounts } from '../../lib/feature-flag' +import { IAheadBehind } from '../../models/branch' const BlankSlateImage = encodePathAsUrl(__dirname, 'static/empty-no-repo.svg') @@ -166,6 +167,66 @@ export class RepositoriesList extends React.Component< ) } + private getAheadBehindTooltip = (aheadBehind: IAheadBehind | null) => { + if (aheadBehind === null) { + return null + } + + const { ahead, behind } = aheadBehind + + if (behind === 0 && ahead === 0) { + return null + } + + return ( + 'The currently checked out branch is' + + (behind ? ` ${commitGrammar(behind)} behind ` : '') + + (behind && ahead ? 'and' : '') + + (ahead ? ` ${commitGrammar(ahead)} ahead of ` : '') + + 'its tracked branch.' + ) + } + + private renderKeyboardFocusTooltip = ( + item: IRepositoryListItem + ): JSX.Element | string | null => { + const { repository, aheadBehind, changedFilesCount } = item + const gitHubRepo = + repository instanceof Repository ? repository.gitHubRepository : null + const alias = repository instanceof Repository ? repository.alias : null + const realName = gitHubRepo ? gitHubRepo.fullName : repository.name + const aheadBehindTooltip = this.getAheadBehindTooltip(aheadBehind) + const hasChanges = changedFilesCount > 0 + const uncommittedChangesTooltip = hasChanges + ? `There are uncommitted changes in this repository.` + : null + + return ( + <> +
+ {realName} + {alias && <> ({alias})} +
+
+ Path: + {repository.path} +
+ {aheadBehindTooltip && ( +
+ Ahead/Behind: + {aheadBehindTooltip} +
+ )} + {uncommittedChangesTooltip && ( +
+ Uncommitted Changes: + {uncommittedChangesTooltip} +
+ )} + + ) + } + private getGroupLabel(group: RepositoryListGroup) { const { kind } = group if (kind === 'enterprise') { @@ -265,6 +326,7 @@ export class RepositoriesList extends React.Component< filterText={this.props.filterText} onFilterTextChanged={this.props.onFilterTextChanged} renderItem={this.renderItem} + renderKeyboardFocusTooltip={this.renderKeyboardFocusTooltip} renderGroupHeader={this.renderGroupHeader} onItemClick={this.onItemClick} renderPostFilter={this.renderPostFilter} diff --git a/app/src/ui/repositories-list/repository-list-item.tsx b/app/src/ui/repositories-list/repository-list-item.tsx index a41b3675993..1566496eed0 100644 --- a/app/src/ui/repositories-list/repository-list-item.tsx +++ b/app/src/ui/repositories-list/repository-list-item.tsx @@ -158,5 +158,5 @@ const renderChangesIndicator = () => { ) } -const commitGrammar = (commitNum: number) => +export const commitGrammar = (commitNum: number) => `${commitNum} commit${commitNum > 1 ? 's' : ''}` // english is hard From 3b751558025f6a5c9c5cfdc7e8c894c9698ee5db Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:11:12 -0400 Subject: [PATCH 009/865] Remove only show on keyboard focus --- app/src/ui/lib/list/list-row.tsx | 1 - app/src/ui/lib/tooltip.tsx | 19 +++++-------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index 19a645dc464..3f1b15846de 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -153,7 +153,6 @@ export class ListRow extends React.Component { ancestorFocused={this.props.selected} target={this.listItemRef} openOnFocus={true} - onlyShowOnKeyboardFocus={true} delay={1000} tooltipOffset={ new DOMRect( diff --git a/app/src/ui/lib/tooltip.tsx b/app/src/ui/lib/tooltip.tsx index 6c49ed92fcb..956b1d9ab2b 100644 --- a/app/src/ui/lib/tooltip.tsx +++ b/app/src/ui/lib/tooltip.tsx @@ -157,8 +157,6 @@ export interface ITooltipProps { * Default: true * */ readonly applyAriaDescribedBy?: boolean - - readonly onlyShowOnKeyboardFocus?: boolean } interface ITooltipState { @@ -400,13 +398,10 @@ export class Tooltip extends React.Component< } private installTooltip(elem: TooltipTarget) { - if (this.props.onlyShowOnKeyboardFocus !== true) { - elem.addEventListener('mouseenter', this.onTargetMouseEnter) - elem.addEventListener('mouseleave', this.onTargetMouseLeave) - elem.addEventListener('mousemove', this.onTargetMouseMove) - elem.addEventListener('mousedown', this.onTargetMouseDown) - } - + elem.addEventListener('mouseenter', this.onTargetMouseEnter) + elem.addEventListener('mouseleave', this.onTargetMouseLeave) + elem.addEventListener('mousemove', this.onTargetMouseMove) + elem.addEventListener('mousedown', this.onTargetMouseDown) elem.addEventListener('focus', this.onTargetFocus) elem.addEventListener('focusin', this.onTargetFocusIn) elem.addEventListener('focusout', this.onTargetBlur) @@ -469,8 +464,6 @@ export class Tooltip extends React.Component< ) { this.beginShowTooltip() } - - console.log('onTargetFocus', this.props.target.current) } private onTargetClick = (event: FocusEvent) => { @@ -581,9 +574,7 @@ export class Tooltip extends React.Component< const { direction, tooltipOffset } = this.props return offsetRect( - direction === undefined && this.props.onlyShowOnKeyboardFocus !== true - ? this.mouseRect - : target.getBoundingClientRect(), + direction === undefined ? this.mouseRect : target.getBoundingClientRect(), tooltipOffset?.x ?? 0, tooltipOffset?.y ?? 0 ) From 71a32d51f9e519f5a7a05c217fc439d2a1a08f6e Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:37:30 -0400 Subject: [PATCH 010/865] Revert "Remove only show on keyboard focus" This reverts commit 3b751558025f6a5c9c5cfdc7e8c894c9698ee5db. --- app/src/ui/lib/list/list-row.tsx | 1 + app/src/ui/lib/tooltip.tsx | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index 3f1b15846de..19a645dc464 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -153,6 +153,7 @@ export class ListRow extends React.Component { ancestorFocused={this.props.selected} target={this.listItemRef} openOnFocus={true} + onlyShowOnKeyboardFocus={true} delay={1000} tooltipOffset={ new DOMRect( diff --git a/app/src/ui/lib/tooltip.tsx b/app/src/ui/lib/tooltip.tsx index 956b1d9ab2b..6c49ed92fcb 100644 --- a/app/src/ui/lib/tooltip.tsx +++ b/app/src/ui/lib/tooltip.tsx @@ -157,6 +157,8 @@ export interface ITooltipProps { * Default: true * */ readonly applyAriaDescribedBy?: boolean + + readonly onlyShowOnKeyboardFocus?: boolean } interface ITooltipState { @@ -398,10 +400,13 @@ export class Tooltip extends React.Component< } private installTooltip(elem: TooltipTarget) { - elem.addEventListener('mouseenter', this.onTargetMouseEnter) - elem.addEventListener('mouseleave', this.onTargetMouseLeave) - elem.addEventListener('mousemove', this.onTargetMouseMove) - elem.addEventListener('mousedown', this.onTargetMouseDown) + if (this.props.onlyShowOnKeyboardFocus !== true) { + elem.addEventListener('mouseenter', this.onTargetMouseEnter) + elem.addEventListener('mouseleave', this.onTargetMouseLeave) + elem.addEventListener('mousemove', this.onTargetMouseMove) + elem.addEventListener('mousedown', this.onTargetMouseDown) + } + elem.addEventListener('focus', this.onTargetFocus) elem.addEventListener('focusin', this.onTargetFocusIn) elem.addEventListener('focusout', this.onTargetBlur) @@ -464,6 +469,8 @@ export class Tooltip extends React.Component< ) { this.beginShowTooltip() } + + console.log('onTargetFocus', this.props.target.current) } private onTargetClick = (event: FocusEvent) => { @@ -574,7 +581,9 @@ export class Tooltip extends React.Component< const { direction, tooltipOffset } = this.props return offsetRect( - direction === undefined ? this.mouseRect : target.getBoundingClientRect(), + direction === undefined && this.props.onlyShowOnKeyboardFocus !== true + ? this.mouseRect + : target.getBoundingClientRect(), tooltipOffset?.x ?? 0, tooltipOffset?.y ?? 0 ) From 5ff39b78f1d4ea36fa186e793840bb50a08d57d2 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:38:33 -0400 Subject: [PATCH 011/865] Update tooltip.tsx --- app/src/ui/lib/tooltip.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/app/src/ui/lib/tooltip.tsx b/app/src/ui/lib/tooltip.tsx index 6c49ed92fcb..aedc3e0d18f 100644 --- a/app/src/ui/lib/tooltip.tsx +++ b/app/src/ui/lib/tooltip.tsx @@ -400,13 +400,10 @@ export class Tooltip extends React.Component< } private installTooltip(elem: TooltipTarget) { - if (this.props.onlyShowOnKeyboardFocus !== true) { - elem.addEventListener('mouseenter', this.onTargetMouseEnter) - elem.addEventListener('mouseleave', this.onTargetMouseLeave) - elem.addEventListener('mousemove', this.onTargetMouseMove) - elem.addEventListener('mousedown', this.onTargetMouseDown) - } - + elem.addEventListener('mouseenter', this.onTargetMouseEnter) + elem.addEventListener('mouseleave', this.onTargetMouseLeave) + elem.addEventListener('mousemove', this.onTargetMouseMove) + elem.addEventListener('mousedown', this.onTargetMouseDown) elem.addEventListener('focus', this.onTargetFocus) elem.addEventListener('focusin', this.onTargetFocusIn) elem.addEventListener('focusout', this.onTargetBlur) @@ -469,8 +466,6 @@ export class Tooltip extends React.Component< ) { this.beginShowTooltip() } - - console.log('onTargetFocus', this.props.target.current) } private onTargetClick = (event: FocusEvent) => { From de9a29a8b4069c27e824bc5f6cc552a019407408 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:43:11 -0400 Subject: [PATCH 012/865] positionRelativeToTarget --- app/src/ui/lib/list/list-row.tsx | 2 +- app/src/ui/lib/tooltip.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index 19a645dc464..5faa93af190 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -153,7 +153,7 @@ export class ListRow extends React.Component { ancestorFocused={this.props.selected} target={this.listItemRef} openOnFocus={true} - onlyShowOnKeyboardFocus={true} + positionRelativeToTarget={true} delay={1000} tooltipOffset={ new DOMRect( diff --git a/app/src/ui/lib/tooltip.tsx b/app/src/ui/lib/tooltip.tsx index aedc3e0d18f..70f744a497e 100644 --- a/app/src/ui/lib/tooltip.tsx +++ b/app/src/ui/lib/tooltip.tsx @@ -158,7 +158,7 @@ export interface ITooltipProps { * */ readonly applyAriaDescribedBy?: boolean - readonly onlyShowOnKeyboardFocus?: boolean + readonly positionRelativeToTarget?: boolean } interface ITooltipState { @@ -576,7 +576,7 @@ export class Tooltip extends React.Component< const { direction, tooltipOffset } = this.props return offsetRect( - direction === undefined && this.props.onlyShowOnKeyboardFocus !== true + direction === undefined && this.props.positionRelativeToTarget !== true ? this.mouseRect : target.getBoundingClientRect(), tooltipOffset?.x ?? 0, From 1c07d290c4d5f343c2345089d57bfc43c46be637 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:17:06 -0400 Subject: [PATCH 013/865] wip --- app/src/ui/branches/branch-list-item.tsx | 10 +++------- app/src/ui/relative-time.tsx | 9 +++++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/src/ui/branches/branch-list-item.tsx b/app/src/ui/branches/branch-list-item.tsx index 7b5282ea755..e94c58ed2d4 100644 --- a/app/src/ui/branches/branch-list-item.tsx +++ b/app/src/ui/branches/branch-list-item.tsx @@ -110,19 +110,15 @@ export class BranchListItem extends React.Component< onMouseUp={this.onMouseUp} > - +
- +
{authorDate && ( )}
diff --git a/app/src/ui/relative-time.tsx b/app/src/ui/relative-time.tsx index 728668dcea9..cc892dedaf2 100644 --- a/app/src/ui/relative-time.tsx +++ b/app/src/ui/relative-time.tsx @@ -18,6 +18,9 @@ interface IRelativeTimeProps { readonly onlyRelative?: boolean readonly className?: string + + /** Whether to show a tooltip with the absolute date on hover - Default = true */ + readonly tooltip?: boolean } interface IRelativeTimeState { @@ -177,6 +180,12 @@ export class RelativeTime extends React.Component< } public render() { + if (this.props.tooltip === false) { + return ( + {this.state.relativeText} + ) + } + return ( Date: Mon, 11 Aug 2025 13:36:26 -0400 Subject: [PATCH 014/865] Update branch-list-item.tsx --- app/src/ui/branches/branch-list-item.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/ui/branches/branch-list-item.tsx b/app/src/ui/branches/branch-list-item.tsx index e94c58ed2d4..d98163a69e8 100644 --- a/app/src/ui/branches/branch-list-item.tsx +++ b/app/src/ui/branches/branch-list-item.tsx @@ -7,7 +7,6 @@ import * as octicons from '../octicons/octicons.generated' import { HighlightText } from '../lib/highlight-text' import { dragAndDropManager } from '../../lib/drag-and-drop-manager' import { DragType, DropTargetType } from '../../models/drag-drop' -import { TooltippedContent } from '../lib/tooltipped-content' import { RelativeTime } from '../relative-time' import classNames from 'classnames' From e856ef68ae2a147fef86cd942ed4bd542e3d6186 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:42:00 -0400 Subject: [PATCH 015/865] Refactor repository list tooltips and focus handling Replaces custom tooltip logic in repository list items with a unified tooltip container and updates visual indicators for ahead/behind and uncommitted changes. Refactors list row components to use a new hasKeyboardFocus prop for improved keyboard navigation feedback. Updates related styles for tooltip layout. --- app/src/ui/lib/list/list-row.tsx | 4 +- app/src/ui/lib/list/list.tsx | 1 + app/src/ui/lib/list/section-list.tsx | 5 +++ .../repositories-list/repositories-list.tsx | 16 +++++-- .../repository-list-item.tsx | 42 ++----------------- app/styles/ui/_repository-list.scss | 8 ++++ 6 files changed, 33 insertions(+), 43 deletions(-) diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index 5faa93af190..20ce0b11ebf 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -128,6 +128,8 @@ interface IListRowProps { readonly renderKeyboardFocusTooltip?: ( indexPath: RowIndexPath ) => JSX.Element | string | null + + readonly hasKeyboardFocus: boolean } export class ListRow extends React.Component { @@ -150,7 +152,7 @@ export class ListRow extends React.Component { return ( { children={element} selectable={selectable} className={customClasses} + hasKeyboardFocus={this.focusRow === rowIndex} /> ) } diff --git a/app/src/ui/lib/list/section-list.tsx b/app/src/ui/lib/list/section-list.tsx index 1313499a1f9..adab06dfaa3 100644 --- a/app/src/ui/lib/list/section-list.tsx +++ b/app/src/ui/lib/list/section-list.tsx @@ -1234,6 +1234,11 @@ export class SectionList extends React.Component< selectable={selectable} className={customClasses} renderKeyboardFocusTooltip={this.props.renderKeyboardFocusTooltip} + hasKeyboardFocus={ + this.focusRow !== InvalidRowIndexPath && + this.focusRow.section === section && + this.focusRow.row === indexPath.row + } /> ) } diff --git a/app/src/ui/repositories-list/repositories-list.tsx b/app/src/ui/repositories-list/repositories-list.tsx index aec68ce9eba..48a5d62caee 100644 --- a/app/src/ui/repositories-list/repositories-list.tsx +++ b/app/src/ui/repositories-list/repositories-list.tsx @@ -201,8 +201,11 @@ export class RepositoriesList extends React.Component< ? `There are uncommitted changes in this repository.` : null + const ahead = aheadBehind?.ahead ?? 0 + const behind = aheadBehind?.behind ?? 0 + return ( - <> +
{realName} {alias && <> ({alias})} @@ -213,17 +216,22 @@ export class RepositoriesList extends React.Component<
{aheadBehindTooltip && (
- Ahead/Behind: +
+ {ahead > 0 && } + {behind > 0 && } +
{aheadBehindTooltip}
)} {uncommittedChangesTooltip && (
- Uncommitted Changes: + + + {uncommittedChangesTooltip}
)} - +
) } diff --git a/app/src/ui/repositories-list/repository-list-item.tsx b/app/src/ui/repositories-list/repository-list-item.tsx index 1566496eed0..5472ffbfe53 100644 --- a/app/src/ui/repositories-list/repository-list-item.tsx +++ b/app/src/ui/repositories-list/repository-list-item.tsx @@ -9,8 +9,6 @@ import { IMatches } from '../../lib/fuzzy-find' import { IAheadBehind } from '../../models/branch' import classNames from 'classnames' import { createObservableRef } from '../lib/observable-ref' -import { Tooltip } from '../lib/tooltip' -import { TooltippedContent } from '../lib/tooltipped-content' interface IRepositoryListItemProps { readonly repository: Repositoryish @@ -55,8 +53,6 @@ export class RepositoryListItem extends React.Component< return (
- {this.renderTooltip()} - ) } - private renderTooltip() { - const repo = this.props.repository - const gitHubRepo = repo instanceof Repository ? repo.gitHubRepository : null - const alias = repo instanceof Repository ? repo.alias : null - const realName = gitHubRepo ? gitHubRepo.fullName : repo.name - - return ( - <> -
- {realName} - {alias && <> ({alias})} -
-
{repo.path}
- - ) - } public shouldComponentUpdate(nextProps: IRepositoryListItemProps): boolean { if ( @@ -128,33 +108,19 @@ const renderAheadBehindIndicator = (aheadBehind: IAheadBehind) => { return null } - const aheadBehindTooltip = - 'The currently checked out branch is' + - (behind ? ` ${commitGrammar(behind)} behind ` : '') + - (behind && ahead ? 'and' : '') + - (ahead ? ` ${commitGrammar(ahead)} ahead of ` : '') + - 'its tracked branch.' - return ( - +
{ahead > 0 && } {behind > 0 && } - +
) } const renderChangesIndicator = () => { return ( - + - + ) } diff --git a/app/styles/ui/_repository-list.scss b/app/styles/ui/_repository-list.scss index 6adfa2d5b65..cde3e30f662 100644 --- a/app/styles/ui/_repository-list.scss +++ b/app/styles/ui/_repository-list.scss @@ -114,6 +114,14 @@ } } + .repository-list-item-tooltip { + > div { + display: flex; + flex-direction: row; + align-items: center; + } + } + .filter-list-group-header { padding-top: var(--spacing); text-overflow: ellipsis; From 41a28c8bef27830072173e8641719b47a2b948bd Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:19:55 -0400 Subject: [PATCH 016/865] Tidying --- app/src/ui/branches/branch-list.tsx | 12 +- app/src/ui/lib/list/list-row.tsx | 25 ++-- app/src/ui/lib/list/list.tsx | 1 - app/src/ui/lib/list/section-list.tsx | 4 +- app/src/ui/lib/section-filter-list.tsx | 10 +- .../repositories-list/repositories-list.tsx | 27 ++-- app/styles/ui/_repository-list.scss | 122 ++++++++++-------- app/styles/ui/window/_tooltips.scss | 14 ++ 8 files changed, 121 insertions(+), 94 deletions(-) diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index 75cbdf2bdb4..b60194738cb 100644 --- a/app/src/ui/branches/branch-list.tsx +++ b/app/src/ui/branches/branch-list.tsx @@ -252,7 +252,7 @@ export class BranchList extends React.Component< onFilterKeyDown={this.props.onFilterKeyDown} selectedItem={this.selectedItem} renderItem={this.renderItem} - renderKeyboardFocusTooltip={this.renderKeyboardFocusTooltip} + renderRowFocusTooltip={this.renderRowFocusTooltip} renderGroupHeader={this.renderGroupHeader} onItemClick={this.onItemClick} onSelectionChanged={this.onSelectionChanged} @@ -311,7 +311,7 @@ export class BranchList extends React.Component< ) } - private renderKeyboardFocusTooltip = ( + private renderRowFocusTooltip = ( item: IBranchListItem ): JSX.Element | string | null => { const { tip, name } = item.branch @@ -325,18 +325,18 @@ export class BranchList extends React.Component< : null return ( - <> +
- Name: +
Full Name:
{name}
{absoluteDate && (
- Date Authored: +
Last Modified:
{absoluteDate}
)} - +
) } diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index 20ce0b11ebf..da75f2d74a5 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames' import { RowIndexPath } from './list-row-index-path' import { Tooltip } from '../tooltip' import { createObservableRef } from '../observable-ref' +import { AriaLiveContainer } from '../../accessibility/aria-live-container' interface IListRowProps { /** whether or not the section to which this row belongs has a header */ @@ -118,18 +119,17 @@ interface IListRowProps { readonly role?: 'option' | 'listitem' | 'presentation' /** - * Optional render function for the keyboard focus tooltip - * - * This is used to render a tooltip when the row is focused via keyboard - * navigation. This should be provided if the row has tooltip content that is - * only accessible via the mouse. The content in the mouse tooltip(s) will - * need to be in the keyboard focus tooltip as well. + * Optional render function for tooltip that appears on keyboard and mouse focus */ - readonly renderKeyboardFocusTooltip?: ( + readonly renderRowFocusTooltip?: ( indexPath: RowIndexPath ) => JSX.Element | string | null - readonly hasKeyboardFocus: boolean + /** Used in conjuction with the above renderRowFocus to communcate keyboard + * focus This must be provided if providing a tooltip on a the list row as it + * enables access to the tooltip for keyboard and screenreader users. + */ + readonly hasKeyboardFocus?: boolean } export class ListRow extends React.Component { @@ -144,8 +144,8 @@ export class ListRow extends React.Component { private renderKeyboardFocusTooltip() { if ( - !this.props.renderKeyboardFocusTooltip || - !this.props.renderKeyboardFocusTooltip(this.props.rowIndex) + !this.props.renderRowFocusTooltip || + !this.props.renderRowFocusTooltip(this.props.rowIndex) ) { return null } @@ -166,7 +166,8 @@ export class ListRow extends React.Component { ) } > - {this.props.renderKeyboardFocusTooltip(this.props.rowIndex)} + + {this.props.renderRowFocusTooltip(this.props.rowIndex)} ) } @@ -294,6 +295,7 @@ export class ListRow extends React.Component { onBlur={this.onBlur} onContextMenu={this.onContextMenu} > + {this.renderKeyboardFocusTooltip()} { // HACK: When we have an ariaLabel we need to make sure that the // child elements are not exposed to the screen reader, otherwise @@ -303,7 +305,6 @@ export class ListRow extends React.Component { className="list-item-content-wrapper" aria-hidden={this.props.ariaLabel !== undefined} > - {this.renderKeyboardFocusTooltip()} {children}
} diff --git a/app/src/ui/lib/list/list.tsx b/app/src/ui/lib/list/list.tsx index dae610f7c99..1813151d63b 100644 --- a/app/src/ui/lib/list/list.tsx +++ b/app/src/ui/lib/list/list.tsx @@ -1214,7 +1214,6 @@ export class List extends React.Component { children={element} selectable={selectable} className={customClasses} - hasKeyboardFocus={this.focusRow === rowIndex} /> ) } diff --git a/app/src/ui/lib/list/section-list.tsx b/app/src/ui/lib/list/section-list.tsx index adab06dfaa3..06c15569c51 100644 --- a/app/src/ui/lib/list/section-list.tsx +++ b/app/src/ui/lib/list/section-list.tsx @@ -75,7 +75,7 @@ interface ISectionListProps { * only accessible via the mouse. The content in the mouse tooltip(s) will * need to be in the keyboard focus tooltip as well. */ - readonly renderKeyboardFocusTooltip?: ( + readonly renderRowFocusTooltip?: ( indexPath: RowIndexPath ) => JSX.Element | string | null @@ -1233,7 +1233,7 @@ export class SectionList extends React.Component< children={element} selectable={selectable} className={customClasses} - renderKeyboardFocusTooltip={this.props.renderKeyboardFocusTooltip} + renderRowFocusTooltip={this.props.renderRowFocusTooltip} hasKeyboardFocus={ this.focusRow !== InvalidRowIndexPath && this.focusRow.section === section && diff --git a/app/src/ui/lib/section-filter-list.tsx b/app/src/ui/lib/section-filter-list.tsx index f4d735915c9..014eb3dde13 100644 --- a/app/src/ui/lib/section-filter-list.tsx +++ b/app/src/ui/lib/section-filter-list.tsx @@ -68,7 +68,7 @@ interface ISectionFilterListProps { * only accessible via the mouse. The content in the mouse tooltip(s) will * need to be in the keyboard focus tooltip as well. */ - readonly renderKeyboardFocusTooltip?: (item: T) => JSX.Element | string | null + readonly renderRowFocusTooltip?: (item: T) => JSX.Element | string | null /** Called to render header for the group with the given identifier. */ readonly renderGroupHeader?: ( @@ -381,7 +381,7 @@ export class SectionFilterList< ref={this.onListRef} rowCount={this.state.rows.map(r => r.length)} rowRenderer={this.renderRow} - renderKeyboardFocusTooltip={this.renderKeyboardFocusTooltip} + renderRowFocusTooltip={this.renderRowFocusTooltip} sectionHasHeader={this.sectionHasHeader} getRowAriaLabel={this.getRowAriaLabel} getSectionAriaLabel={this.getSectionAriaLabel} @@ -450,14 +450,14 @@ export class SectionFilterList< } } - private renderKeyboardFocusTooltip = ( + private renderRowFocusTooltip = ( index: RowIndexPath ): JSX.Element | string | null => { const row = this.state.rows[index.section][index.row] - if (row.kind !== 'item' || !this.props.renderKeyboardFocusTooltip) { + if (row.kind !== 'item' || !this.props.renderRowFocusTooltip) { return null } - return this.props.renderKeyboardFocusTooltip(row.item) + return this.props.renderRowFocusTooltip(row.item) } private onTextBoxRef = (component: TextBox | null) => { diff --git a/app/src/ui/repositories-list/repositories-list.tsx b/app/src/ui/repositories-list/repositories-list.tsx index 48a5d62caee..4e089bb4bac 100644 --- a/app/src/ui/repositories-list/repositories-list.tsx +++ b/app/src/ui/repositories-list/repositories-list.tsx @@ -187,7 +187,7 @@ export class RepositoriesList extends React.Component< ) } - private renderKeyboardFocusTooltip = ( + private renderRowFocusTooltip = ( item: IRepositoryListItem ): JSX.Element | string | null => { const { repository, aheadBehind, changedFilesCount } = item @@ -205,29 +205,34 @@ export class RepositoriesList extends React.Component< const behind = aheadBehind?.behind ?? 0 return ( -
+
- {realName} +
Full Name:
+ {realName} {alias && <> ({alias})}
- Path: +
Path:
{repository.path}
{aheadBehindTooltip && (
-
- {ahead > 0 && } - {behind > 0 && } +
+
+ {ahead > 0 && } + {behind > 0 && } +
{aheadBehindTooltip}
)} {uncommittedChangesTooltip && (
- - - +
+ + + +
{uncommittedChangesTooltip}
)} @@ -334,7 +339,7 @@ export class RepositoriesList extends React.Component< filterText={this.props.filterText} onFilterTextChanged={this.props.onFilterTextChanged} renderItem={this.renderItem} - renderKeyboardFocusTooltip={this.renderKeyboardFocusTooltip} + renderRowFocusTooltip={this.renderRowFocusTooltip} renderGroupHeader={this.renderGroupHeader} onItemClick={this.onItemClick} renderPostFilter={this.renderPostFilter} diff --git a/app/styles/ui/_repository-list.scss b/app/styles/ui/_repository-list.scss index cde3e30f662..655e21870ea 100644 --- a/app/styles/ui/_repository-list.scss +++ b/app/styles/ui/_repository-list.scss @@ -65,61 +65,6 @@ .alias { font-style: italic; } - - .repo-indicators { - margin-left: auto; - display: flex; - justify-content: flex-end; - align-items: center; - margin-right: var(--spacing-half); - } - - .change-indicator-wrapper { - display: flex; - min-width: 12px; - justify-content: center; - align-items: center; - margin-left: var(--spacing-half); - - .octicon { - color: var(--tab-bar-active-color); - width: auto; - } - } - - .ahead-behind { - height: 16px; - background: var(--list-item-badge-background-color); - color: var(--list-item-badge-color); - align-items: center; - margin-left: auto; - - // Perfectly round semi circle ends with real tight - // padding on either side. Now in two flavors! - @include darwin { - height: 12px; - line-height: 12px; - } - - @include win32 { - height: 13px; - line-height: 13px; - } - - .octicon { - margin: 0; - height: 20px; - width: 12px; - } - } - } - - .repository-list-item-tooltip { - > div { - display: flex; - flex-direction: row; - align-items: center; - } } .filter-list-group-header { @@ -185,7 +130,8 @@ .list-focus-container { /** Ahead/behind badge colors when list item is selected but not focused */ .list-item.selected { - .repository-list-item { + .repository-list-item, + .repository-list-item-tooltip { .ahead-behind { background: var(--list-item-selected-badge-background-color); color: var(--list-item-selected-badge-color); @@ -196,7 +142,8 @@ &.focus-within { /** Ahead/behind badge colors when list item is selected and focused */ .list-item.selected { - .repository-list-item { + .repository-list-item, + .repository-list-item-tooltip { .ahead-behind { background: var(--list-item-selected-active-badge-background-color); color: var(--list-item-selected-active-badge-color); @@ -215,3 +162,64 @@ } } } + +.repository-list, +.repository-list-item-tooltip { + .repo-indicators { + margin-left: auto; + display: flex; + justify-content: flex-end; + align-items: center; + margin-right: var(--spacing-half); + } + + .change-indicator-wrapper { + display: flex; + min-width: 12px; + justify-content: center; + align-items: center; + margin-left: var(--spacing-half); + + .octicon { + color: var(--tab-bar-active-color); + width: auto; + } + } + + .ahead-behind { + height: 16px; + background: var(--list-item-badge-background-color); + color: var(--list-item-badge-color); + align-items: center; + margin-left: auto; + + // Perfectly round semi circle ends with real tight + // padding on either side. Now in two flavors! + @include darwin { + height: 12px; + line-height: 12px; + } + + @include win32 { + height: 13px; + line-height: 13px; + } + + .octicon { + margin: 0; + height: 20px; + width: 12px; + } + } +} + +.repository-list-item-tooltip { + .ahead-behind { + display: inline-flex; + margin: unset; + } + + .change-indicator-wrapper { + justify-content: unset; + } +} diff --git a/app/styles/ui/window/_tooltips.scss b/app/styles/ui/window/_tooltips.scss index ffcb020ebf1..3e76b9ca5ab 100644 --- a/app/styles/ui/window/_tooltips.scss +++ b/app/styles/ui/window/_tooltips.scss @@ -220,6 +220,20 @@ body > .tooltip, border-bottom-color: var(--toolbar-tooltip-background-color); } } + + .list-item-tooltip { + > div { + display: flex; + flex-direction: row; + margin-bottom: var(--spacing-half); + } + + .label { + min-width: 60px; + margin-right: var(--spacing-half); + font-weight: bold; + } + } } .tooltip-host { From ebe3625fd2a4ceb25bf0dbd60139652dc02732bd Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:14:53 -0400 Subject: [PATCH 017/865] Tidying --- app/src/ui/lib/list/list-row.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index da75f2d74a5..ef0f48a5d5a 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import classNames from 'classnames' import { RowIndexPath } from './list-row-index-path' import { Tooltip } from '../tooltip' -import { createObservableRef } from '../observable-ref' +import { createObservableRef, ObservableRef } from '../observable-ref' import { AriaLiveContainer } from '../../accessibility/aria-live-container' interface IListRowProps { @@ -140,10 +140,11 @@ export class ListRow extends React.Component { // event, with no keyDown events (since that keyDown event should've happened // in the component that previously had focus). private keyboardFocusDetectionState: 'ready' | 'failed' | 'focused' = 'ready' - private readonly listItemRef = createObservableRef() + private listItemRef: ObservableRef | null = null - private renderKeyboardFocusTooltip() { + private renderFocusTooltip() { if ( + !this.listItemRef || !this.props.renderRowFocusTooltip || !this.props.renderRowFocusTooltip(this.props.rowIndex) ) { @@ -172,10 +173,11 @@ export class ListRow extends React.Component { ) } - public componentDidMount() { - this.listItemRef.subscribe(elem => { - this.props.onRowRef?.(this.props.rowIndex, elem) - }) + private onRowRef = (elem: HTMLDivElement | null) => { + if (elem) { + this.listItemRef = createObservableRef(elem) + } + this.props.onRowRef?.(this.props.rowIndex, elem) } private onRowMouseDown = (e: React.MouseEvent) => { @@ -283,7 +285,7 @@ export class ListRow extends React.Component { aria-label={this.props.ariaLabel} className={rowClassName} tabIndex={tabIndex} - ref={this.listItemRef} + ref={this.onRowRef} onMouseDown={this.onRowMouseDown} onMouseUp={this.onRowMouseUp} onClick={this.onRowClick} @@ -295,7 +297,7 @@ export class ListRow extends React.Component { onBlur={this.onBlur} onContextMenu={this.onContextMenu} > - {this.renderKeyboardFocusTooltip()} + {this.renderFocusTooltip()} { // HACK: When we have an ariaLabel we need to make sure that the // child elements are not exposed to the screen reader, otherwise From 9309f32c45cc45e5cfe12a142800e9f7524d9140 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:28:33 -0400 Subject: [PATCH 018/865] Update list-row.tsx --- app/src/ui/lib/list/list-row.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index ef0f48a5d5a..191e0560c0f 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -157,7 +157,6 @@ export class ListRow extends React.Component { target={this.listItemRef} openOnFocus={true} positionRelativeToTarget={true} - delay={1000} tooltipOffset={ new DOMRect( this.listItemRef.current From 7a089fba7fa8ae8e565a3f04406c53f5af2a5e13 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:11:02 -0400 Subject: [PATCH 019/865] Commit list wip --- app/src/ui/history/commit-list.tsx | 89 +++++++++++++++++++++++++ app/src/ui/lib/list/list.tsx | 9 +++ app/styles/ui/history/_commit-list.scss | 7 ++ 3 files changed, 105 insertions(+) diff --git a/app/src/ui/history/commit-list.tsx b/app/src/ui/history/commit-list.tsx index fcd47c84c3b..a683013654b 100644 --- a/app/src/ui/history/commit-list.tsx +++ b/app/src/ui/history/commit-list.tsx @@ -28,6 +28,11 @@ import { import { KeyboardShortcut } from '../keyboard-shortcut/keyboard-shortcut' import { Account } from '../../models/account' import { Emoji } from '../../lib/emoji' +import { getAvatarUsersForCommit, IAvatarUser } from '../../models/avatar' +import { formatDate } from '../../lib/format-date' +import { Avatar } from '../lib/avatar' +import { Octicon } from '../octicons' +import * as octicons from '../octicons/octicons.generated' const RowHeight = 50 @@ -464,6 +469,89 @@ export class CommitList extends React.Component< return rowClassMap } + private renderExpandedAuthor(user: IAvatarUser): string | JSX.Element { + if (!user) { + return 'Unknown user' + } + + if (user.name) { + return ( + <> + {user.name} + {' <'} + {user.email} + {'>'} + + ) + } + + return user.email + } + + private renderRowFocusTooltip = (indexPath: RowIndexPath | undefined) => { + if (!indexPath) { + return null + } + const row = indexPath.row + const sha = this.props.commitSHAs[row] + const commit = this.props.commitLookup.get(sha) + if (!commit) { + return null + } + + const avatarUsers = getAvatarUsersForCommit( + this.props.gitHubRepository, + commit + ) + + const { + author: { date }, + } = commit + + const absoluteDate = formatDate(date, { + dateStyle: 'full', + timeStyle: 'short', + }) + + const authorList = avatarUsers.map((user, i) => { + return ( +
+
+ +
+
{this.renderExpandedAuthor(user)}
+
+ ) + }) + + const isLocal = this.isLocalCommit(commit.sha) + const unpushedTags = this.getUnpushedTags(commit) + + const showUnpushedIndicator = + (isLocal || unpushedTags.length > 0) && + this.props.isLocalRepository === false + + return ( +
+ {authorList} +
+
Date:
+ {absoluteDate} +
+ {showUnpushedIndicator ? ( +
+
+ +
+
+ {this.getUnpushedIndicatorTitle(isLocal, unpushedTags.length)} +
+
+ ) : null} +
+ ) + } + public focus() { this.listRef.current?.focus() } @@ -533,6 +621,7 @@ export class CommitList extends React.Component< }} setScrollTop={this.props.compareListScrollTop} rowCustomClassNameMap={this.getRowCustomClassMap()} + renderRowFocusTooltip={this.renderRowFocusTooltip} />
diff --git a/app/src/ui/lib/list/list.tsx b/app/src/ui/lib/list/list.tsx index 1813151d63b..43b3f0d492c 100644 --- a/app/src/ui/lib/list/list.tsx +++ b/app/src/ui/lib/list/list.tsx @@ -341,6 +341,13 @@ interface IListProps { indexPath: RowIndexPath, data: KeyboardInsertionData ) => void + + /** + * Optional render function for the keyboard focus tooltip + */ + readonly renderRowFocusTooltip?: ( + indexPath: RowIndexPath + ) => JSX.Element | string | null } interface IListState { @@ -1214,6 +1221,8 @@ export class List extends React.Component { children={element} selectable={selectable} className={customClasses} + hasKeyboardFocus={this.focusRow === rowIndex} + renderRowFocusTooltip={this.props.renderRowFocusTooltip} /> ) } diff --git a/app/styles/ui/history/_commit-list.scss b/app/styles/ui/history/_commit-list.scss index 0178698725c..eb05c061675 100644 --- a/app/styles/ui/history/_commit-list.scss +++ b/app/styles/ui/history/_commit-list.scss @@ -187,3 +187,10 @@ display: none; } } + +.commit-list-item-tooltip { + .avatar { + width: 16px; + height: 16px; + } +} From 7eaae36de1769f1459c66b440157acc9b4c1eb6a Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:44:52 -0400 Subject: [PATCH 020/865] Tidying --- app/src/ui/history/commit-list-item.tsx | 12 +++------ app/src/ui/history/commit-list.tsx | 18 ++++++-------- app/src/ui/lib/avatar.tsx | 33 ++++++++++++++++++------- app/src/ui/lib/list/list-row.tsx | 1 + app/styles/ui/history/_commit-list.scss | 27 +++++++++++++++++--- 5 files changed, 59 insertions(+), 32 deletions(-) diff --git a/app/src/ui/history/commit-list-item.tsx b/app/src/ui/history/commit-list-item.tsx index 3584c8fcb17..776cf1697fb 100644 --- a/app/src/ui/history/commit-list-item.tsx +++ b/app/src/ui/history/commit-list-item.tsx @@ -22,7 +22,6 @@ import { DropTargetType, } from '../../models/drag-drop' import classNames from 'classnames' -import { TooltippedContent } from '../lib/tooltipped-content' import { Account } from '../../models/account' import { Emoji } from '../../lib/emoji' @@ -44,7 +43,6 @@ interface ICommitProps { */ readonly isDraggable?: boolean readonly showUnpushedIndicator: boolean - readonly unpushedIndicatorTitle?: string readonly disableSquashing?: boolean readonly accounts: ReadonlyArray } @@ -198,13 +196,9 @@ export class CommitListItem extends React.PureComponent< } return ( - +
- +
) } @@ -237,7 +231,7 @@ function renderRelativeTime(date: Date) { return ( <> {` • `} - + ) } diff --git a/app/src/ui/history/commit-list.tsx b/app/src/ui/history/commit-list.tsx index a683013654b..04bafcf5766 100644 --- a/app/src/ui/history/commit-list.tsx +++ b/app/src/ui/history/commit-list.tsx @@ -296,10 +296,6 @@ export class CommitList extends React.Component< key={commit.sha} gitHubRepository={this.props.gitHubRepository} showUnpushedIndicator={showUnpushedIndicator} - unpushedIndicatorTitle={this.getUnpushedIndicatorTitle( - isLocal, - unpushedTags.length - )} commit={commit} emoji={this.props.emoji} isDraggable={ @@ -477,10 +473,8 @@ export class CommitList extends React.Component< if (user.name) { return ( <> - {user.name} - {' <'} - {user.email} - {'>'} +
{user.name}
+
{user.email}
) } @@ -515,7 +509,7 @@ export class CommitList extends React.Component< const authorList = avatarUsers.map((user, i) => { return ( -
+
@@ -539,9 +533,11 @@ export class CommitList extends React.Component< {absoluteDate}
{showUnpushedIndicator ? ( -
+
- + + +
{this.getUnpushedIndicatorTitle(isLocal, unpushedTags.length)} diff --git a/app/src/ui/lib/avatar.tsx b/app/src/ui/lib/avatar.tsx index b879415d3dc..60bd9189bf8 100644 --- a/app/src/ui/lib/avatar.tsx +++ b/app/src/ui/lib/avatar.tsx @@ -168,6 +168,9 @@ interface IAvatarProps { readonly size?: number readonly accounts: ReadonlyArray + + /** Defaults true */ + readonly tooltip?: boolean } interface IAvatarState { @@ -371,11 +374,29 @@ export class Avatar extends React.Component { public render() { const title = this.getTitle() + + if (this.props.tooltip === false) { + return
{this.renderAvatar()}
+ } + + return ( + + {this.renderAvatar()} + + ) + } + + private renderAvatar = () => { const { imageError, user } = this.state const alt = user ? `Avatar for ${user.name || user.email}` : `Avatar for unknown user` - const now = Date.now() const src = this.state.candidates.find(c => { const lastFailed = FailingAvatars.get(c) @@ -383,13 +404,7 @@ export class Avatar extends React.Component { }) return ( - + <> {(!src || imageError) && ( )} @@ -407,7 +422,7 @@ export class Avatar extends React.Component { style={{ display: imageError ? 'none' : undefined }} /> )} - + ) } diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index 191e0560c0f..530cd12d40a 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -157,6 +157,7 @@ export class ListRow extends React.Component { target={this.listItemRef} openOnFocus={true} positionRelativeToTarget={true} + delay={this.props.hasKeyboardFocus ? 1000 : undefined} tooltipOffset={ new DOMRect( this.listItemRef.current diff --git a/app/styles/ui/history/_commit-list.scss b/app/styles/ui/history/_commit-list.scss index eb05c061675..fada8459a7b 100644 --- a/app/styles/ui/history/_commit-list.scss +++ b/app/styles/ui/history/_commit-list.scss @@ -189,8 +189,29 @@ } .commit-list-item-tooltip { - .avatar { - width: 16px; - height: 16px; + &.list-item-tooltip { + .label { + min-width: 35px !important; + } + + .author { + align-items: center; + + .avatar { + width: 24px; + height: 24px; + } + } + + .unpushed-indicator { + display: inline-flex; + flex: 0 0 auto; + border-radius: 8px; + padding: 0 var(--spacing-half); + color: var(--list-item-badge-color); + align-items: center; + background: var(--list-item-selected-badge-background-color); + margin-top: 3px; + } } } From a69d6c33ac03bc90d900cbfc099ead4687a8c48d Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 11 Aug 2025 21:45:05 -0400 Subject: [PATCH 021/865] Removing avatar stack tooltip --- app/src/ui/history/commit-list-item.tsx | 1 + app/src/ui/lib/avatar-stack.tsx | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/ui/history/commit-list-item.tsx b/app/src/ui/history/commit-list-item.tsx index 776cf1697fb..ad2a376a487 100644 --- a/app/src/ui/history/commit-list-item.tsx +++ b/app/src/ui/history/commit-list-item.tsx @@ -158,6 +158,7 @@ export class CommitListItem extends React.PureComponent<
readonly accounts: ReadonlyArray + /** Defaults: true */ + readonly tooltip?: boolean } /** @@ -24,7 +26,7 @@ interface IAvatarStackProps { export class AvatarStack extends React.Component { public render() { const elems = [] - const { users, accounts } = this.props + const { users, accounts, tooltip } = this.props for (let i = 0; i < this.props.users.length; i++) { if ( @@ -34,7 +36,14 @@ export class AvatarStack extends React.Component { elems.push(
) } - elems.push() + elems.push( + + ) } const className = classNames('AvatarStack', { From 18e83537aff28dc78e4af6528329e3b83d279a54 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 12 Aug 2025 06:35:55 -0400 Subject: [PATCH 022/865] Tidying --- app/src/ui/lib/list/list-row.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index 530cd12d40a..cbce78a30a6 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -120,16 +120,19 @@ interface IListRowProps { /** * Optional render function for tooltip that appears on keyboard and mouse focus + * + * See other prop `hasKeyboardFocus` if using this method. */ readonly renderRowFocusTooltip?: ( indexPath: RowIndexPath ) => JSX.Element | string | null - /** Used in conjuction with the above renderRowFocus to communcate keyboard - * focus This must be provided if providing a tooltip on a the list row as it - * enables access to the tooltip for keyboard and screenreader users. + /** + * Used in conjunction with the above renderRowFocus to communicate keyboard + * focus This must be provided if providing a tooltip on a the list row as it + * enables access to the tooltip for keyboard and screenreader users. */ - readonly hasKeyboardFocus?: boolean + readonly hasKeyboardFocus: boolean } export class ListRow extends React.Component { From 70d03e70525caeb287eebe7e956cd3a6877de4cd Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 12 Aug 2025 06:41:44 -0400 Subject: [PATCH 023/865] Tidying --- app/src/ui/lib/tooltip.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/ui/lib/tooltip.tsx b/app/src/ui/lib/tooltip.tsx index 70f744a497e..03c623e8f71 100644 --- a/app/src/ui/lib/tooltip.tsx +++ b/app/src/ui/lib/tooltip.tsx @@ -158,6 +158,9 @@ export interface ITooltipProps { * */ readonly applyAriaDescribedBy?: boolean + /** Usually the position of the tooltip is relative to the mouse pointer, this + * forces it to be relative to the target's position. Useful for tooltips + * rendered by keyboard focus of the item. */ readonly positionRelativeToTarget?: boolean } From 914339a3cfb6888fdc8bc8f578f7f3b26c14f213 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 12 Aug 2025 06:59:45 -0400 Subject: [PATCH 024/865] Tidying --- app/src/ui/lib/list/list-row.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index cbce78a30a6..68403f31230 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -3,7 +3,6 @@ import classNames from 'classnames' import { RowIndexPath } from './list-row-index-path' import { Tooltip } from '../tooltip' import { createObservableRef, ObservableRef } from '../observable-ref' -import { AriaLiveContainer } from '../../accessibility/aria-live-container' interface IListRowProps { /** whether or not the section to which this row belongs has a header */ @@ -170,7 +169,6 @@ export class ListRow extends React.Component { ) } > - {this.props.renderRowFocusTooltip(this.props.rowIndex)} ) From d933f869d3f19f4f2a5c752bd0549f46364a5a49 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 12 Aug 2025 08:04:16 -0400 Subject: [PATCH 025/865] Fix keyboard focus management in conflict resolution dialogs - Addresses accessibility issue where keyboard focus was not landing on the first interactive element in 'Resolve conflicts before Cherry pick' and 'Resolve conflicts before Merge' dialogs - Added DialogPreferredFocusClassName to the first interactive control in conflict lists - For manual conflicts: applies to the 'Resolve' button - For conflicts with markers: applies to the 'Open in editor' button - Ensures compliance with WCAG 2.4.3 Focus Order guidelines - Improves user experience for keyboard-only users Fixes: Focus now lands on first interactive control instead of 'Continue' button --- app/src/ui/lib/conflicts/unmerged-file.tsx | 19 +++++++- .../dialog/conflicts-dialog.tsx | 43 +++++++++++-------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/app/src/ui/lib/conflicts/unmerged-file.tsx b/app/src/ui/lib/conflicts/unmerged-file.tsx index 9a2dbf1f483..be19a9b0a8c 100644 --- a/app/src/ui/lib/conflicts/unmerged-file.tsx +++ b/app/src/ui/lib/conflicts/unmerged-file.tsx @@ -28,6 +28,7 @@ import { getLabelForManualResolutionOption, } from '../../../lib/status' import { revealInFileManager } from '../../../lib/app-shell' +import { DialogPreferredFocusClassName } from '../../dialog' const defaultConflictsResolvedMessage = 'No conflicts remaining' @@ -78,6 +79,8 @@ export const renderUnmergedFile: React.FunctionComponent<{ readonly setIsFileResolutionOptionsMenuOpen: ( isFileResolutionOptionsMenuOpen: boolean ) => void + /** whether this is the first conflicted file in the dialog (for focus management) */ + readonly isFirstConflictedFile?: boolean }> = props => { if ( isConflictWithMarkers(props.status) && @@ -96,6 +99,7 @@ export const renderUnmergedFile: React.FunctionComponent<{ isFileResolutionOptionsMenuOpen: props.isFileResolutionOptionsMenuOpen, setIsFileResolutionOptionsMenuOpen: props.setIsFileResolutionOptionsMenuOpen, + isFirstConflictedFile: props.isFirstConflictedFile, }) } if ( @@ -109,6 +113,7 @@ export const renderUnmergedFile: React.FunctionComponent<{ dispatcher: props.dispatcher, ourBranch: props.ourBranch, theirBranch: props.theirBranch, + isFirstConflictedFile: props.isFirstConflictedFile, }) } return renderResolvedFile({ @@ -174,6 +179,7 @@ const renderManualConflictedFile: React.FunctionComponent<{ readonly ourBranch?: string readonly theirBranch?: string readonly dispatcher: Dispatcher + readonly isFirstConflictedFile?: boolean }> = props => { const onDropdownClick = makeManualConflictDropdownClickHandler( props.path, @@ -210,6 +216,10 @@ const renderManualConflictedFile: React.FunctionComponent<{ conflictTypeString = `File does not exist on ${targetBranch}.` } + const resolveButtonClassName = props.isFirstConflictedFile + ? `small-button button-group-item resolve-arrow-menu ${DialogPreferredFocusClassName}` + : 'small-button button-group-item resolve-arrow-menu' + const content = ( <>
@@ -218,7 +228,7 @@ const renderManualConflictedFile: React.FunctionComponent<{
diff --git a/app/src/ui/multi-commit-operation/dialog/conflicts-dialog.tsx b/app/src/ui/multi-commit-operation/dialog/conflicts-dialog.tsx index c9f29583375..ff8bdf994ef 100644 --- a/app/src/ui/multi-commit-operation/dialog/conflicts-dialog.tsx +++ b/app/src/ui/multi-commit-operation/dialog/conflicts-dialog.tsx @@ -146,27 +146,32 @@ export class ConflictsDialog extends React.Component< private renderUnmergedFiles( files: ReadonlyArray ) { + let isFirstUnmergedFile = true return (
    - {files.map(f => - isConflictedFile(f.status) - ? renderUnmergedFile({ - path: f.path, - status: f.status, - resolvedExternalEditor: this.props.resolvedExternalEditor, - openFileInExternalEditor: this.props.openFileInExternalEditor, - repository: this.props.repository, - dispatcher: this.props.dispatcher, - manualResolution: this.props.manualResolutions.get(f.path), - ourBranch: this.props.ourBranch, - theirBranch: this.props.theirBranch, - isFileResolutionOptionsMenuOpen: - this.state.isFileResolutionOptionsMenuOpen, - setIsFileResolutionOptionsMenuOpen: - this.setIsFileResolutionOptionsMenuOpen, - }) - : null - )} + {files.map(f => { + if (isConflictedFile(f.status)) { + const isFirst = isFirstUnmergedFile + isFirstUnmergedFile = false + return renderUnmergedFile({ + path: f.path, + status: f.status, + resolvedExternalEditor: this.props.resolvedExternalEditor, + openFileInExternalEditor: this.props.openFileInExternalEditor, + repository: this.props.repository, + dispatcher: this.props.dispatcher, + manualResolution: this.props.manualResolutions.get(f.path), + ourBranch: this.props.ourBranch, + theirBranch: this.props.theirBranch, + isFileResolutionOptionsMenuOpen: + this.state.isFileResolutionOptionsMenuOpen, + setIsFileResolutionOptionsMenuOpen: + this.setIsFileResolutionOptionsMenuOpen, + isFirstConflictedFile: isFirst, + }) + } + return null + })}
) } From 84277e15c85c483a5cfe98273d4b4e815aab3ae2 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:40:32 -0400 Subject: [PATCH 026/865] Release 3.5.3-beta1 --- app/package.json | 2 +- changelog.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/package.json b/app/package.json index f56c168b65b..61548066fd1 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.5.2", + "version": "3.5.3-beta1", "main": "./main.js", "repository": { "type": "git", diff --git a/changelog.json b/changelog.json index aa117011fd8..d6eeedfe246 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,9 @@ { "releases": { + "3.5.3-beta1": [ + "[Improved] Provides the tooltips for list items in a single condensed tooltip that allows keyboard users and screen reader users access upon navigation of a list item - #20804", + "[Fixed] Focus lands on first interactive control instead of 'Continue' button in the conflict resolution dialog - #20880" + ], "3.5.2": [ "[Fixed] Fix the crash that sometimes occurs when opening Pull Request-related notifications - #20761", "[Fixed] Ensure the cursor type on links is pointer - #20766. Thanks @huanfe1!", From ce2f21f1da9d104973ccd30f65e5464968b1a46a Mon Sep 17 00:00:00 2001 From: Melissa Xie Date: Fri, 15 Aug 2025 17:48:55 -0400 Subject: [PATCH 027/865] Fix luminosity ratio for conflicted file error text - Change --color-conflicted from orange-600 (#e36209) to orange-800 (#c24e00) - Improves contrast ratio from 3.49:1 to 4.79:1 against white background - Meets WCAG 2.1 AA compliance requirement of 4.5:1 for normal text - Addresses accessibility audit issue github/accessibility-audits#12464 --- app/styles/_variables.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/styles/_variables.scss b/app/styles/_variables.scss index 8ad90375ab9..1207787723a 100644 --- a/app/styles/_variables.scss +++ b/app/styles/_variables.scss @@ -16,7 +16,7 @@ $overlay-background-color: rgba(0, 0, 0, 0.4); --color-deleted: #{$red-600}; --color-modified: #{darken($yellow-700, 10%)}; --color-renamed: #{$blue}; - --color-conflicted: #{$orange-600}; + --color-conflicted: #{$orange-800}; --text-color: #{$gray-900}; --text-secondary-color: #{$gray-500}; From 59352097594917f1ad7d3498132bd556d82e35fd Mon Sep 17 00:00:00 2001 From: Robbie Rotman Date: Wed, 27 Aug 2025 17:52:57 +1000 Subject: [PATCH 028/865] fix - added css gap property to the no-changes panel #7500 --- app/styles/ui/_repository.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/styles/ui/_repository.scss b/app/styles/ui/_repository.scss index b5f60ac6fc1..fef83695321 100644 --- a/app/styles/ui/_repository.scss +++ b/app/styles/ui/_repository.scss @@ -13,6 +13,10 @@ display: flex; } + #no-changes { + gap: var(--spacing); + } + &-sidebar { display: flex; flex-direction: column; From 9ddebd72ac7ed038187ab0b1e10caa279c47bac3 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 2 Sep 2025 09:18:11 -0400 Subject: [PATCH 029/865] Add accessible list tooltips feature flag Introduces the enableAccessibleListToolTips feature flag to control tooltip accessibility in list components. Updates branch, commit, and repository list items to conditionally render tooltips based on the flag, improving keyboard navigation and accessibility. Refactors tooltip logic to support disabling tooltips and adds contextual tooltips to repository indicators. --- app/src/lib/feature-flag.ts | 4 ++ app/src/ui/branches/branch-list-item.tsx | 14 ++++- app/src/ui/history/commit-list-item.tsx | 14 ++++- app/src/ui/history/commit-list.tsx | 4 ++ app/src/ui/lib/list/list-row.tsx | 5 ++ app/src/ui/lib/tooltip.tsx | 7 +++ .../repository-list-item.tsx | 56 ++++++++++++++++--- 7 files changed, 90 insertions(+), 14 deletions(-) diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index 2832f8037be..d249d067484 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -116,3 +116,7 @@ export const enableCommitMessageGeneration = (account: Account) => { account.isCopilotDesktopEnabled ) } + +export function enableAccessibleListToolTips(): boolean { + return enableBetaFeatures() +} diff --git a/app/src/ui/branches/branch-list-item.tsx b/app/src/ui/branches/branch-list-item.tsx index d98163a69e8..41da890f2f3 100644 --- a/app/src/ui/branches/branch-list-item.tsx +++ b/app/src/ui/branches/branch-list-item.tsx @@ -9,6 +9,8 @@ import { dragAndDropManager } from '../../lib/drag-and-drop-manager' import { DragType, DropTargetType } from '../../models/drag-drop' import { RelativeTime } from '../relative-time' import classNames from 'classnames' +import { TooltippedContent } from '../lib/tooltipped-content' +import { enableAccessibleListToolTips } from '../../lib/feature-flag' interface IBranchListItemProps { /** The name of the branch */ @@ -109,15 +111,21 @@ export class BranchListItem extends React.Component< onMouseUp={this.onMouseUp} > -
+ -
+ {authorDate && ( )}
diff --git a/app/src/ui/history/commit-list-item.tsx b/app/src/ui/history/commit-list-item.tsx index ad2a376a487..301b4ed9842 100644 --- a/app/src/ui/history/commit-list-item.tsx +++ b/app/src/ui/history/commit-list-item.tsx @@ -24,6 +24,8 @@ import { import classNames from 'classnames' import { Account } from '../../models/account' import { Emoji } from '../../lib/emoji' +import { enableAccessibleListToolTips } from '../../lib/feature-flag' +import { TooltippedContent } from '../lib/tooltipped-content' interface ICommitProps { readonly gitHubRepository: GitHubRepository | null @@ -44,6 +46,7 @@ interface ICommitProps { readonly isDraggable?: boolean readonly showUnpushedIndicator: boolean readonly disableSquashing?: boolean + readonly unpushedIndicatorTitle?: string readonly accounts: ReadonlyArray } @@ -158,7 +161,7 @@ export class CommitListItem extends React.PureComponent<
+ -
+ ) } diff --git a/app/src/ui/history/commit-list.tsx b/app/src/ui/history/commit-list.tsx index 04bafcf5766..de7a2f01098 100644 --- a/app/src/ui/history/commit-list.tsx +++ b/app/src/ui/history/commit-list.tsx @@ -296,6 +296,10 @@ export class CommitList extends React.Component< key={commit.sha} gitHubRepository={this.props.gitHubRepository} showUnpushedIndicator={showUnpushedIndicator} + unpushedIndicatorTitle={this.getUnpushedIndicatorTitle( + isLocal, + unpushedTags.length + )} commit={commit} emoji={this.props.emoji} isDraggable={ diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index 68403f31230..ae72e9bd3e4 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames' import { RowIndexPath } from './list-row-index-path' import { Tooltip } from '../tooltip' import { createObservableRef, ObservableRef } from '../observable-ref' +import { enableAccessibleListToolTips } from '../../../lib/feature-flag' interface IListRowProps { /** whether or not the section to which this row belongs has a header */ @@ -145,6 +146,10 @@ export class ListRow extends React.Component { private listItemRef: ObservableRef | null = null private renderFocusTooltip() { + if (!enableAccessibleListToolTips()) { + return null + } + if ( !this.listItemRef || !this.props.renderRowFocusTooltip || diff --git a/app/src/ui/lib/tooltip.tsx b/app/src/ui/lib/tooltip.tsx index 03c623e8f71..f4417bf93d9 100644 --- a/app/src/ui/lib/tooltip.tsx +++ b/app/src/ui/lib/tooltip.tsx @@ -162,6 +162,9 @@ export interface ITooltipProps { * forces it to be relative to the target's position. Useful for tooltips * rendered by keyboard focus of the item. */ readonly positionRelativeToTarget?: boolean + + /** Whether to show the tooltip when the target is focused */ + readonly disabled?: boolean } interface ITooltipState { @@ -528,6 +531,10 @@ export class Tooltip extends React.Component< } private beginShowTooltip() { + if (this.props.disabled) { + return + } + this.cancelShowTooltip() this.showTooltipTimeout = window.setTimeout( this.showTooltip, diff --git a/app/src/ui/repositories-list/repository-list-item.tsx b/app/src/ui/repositories-list/repository-list-item.tsx index 5472ffbfe53..a0419390c7b 100644 --- a/app/src/ui/repositories-list/repository-list-item.tsx +++ b/app/src/ui/repositories-list/repository-list-item.tsx @@ -9,6 +9,9 @@ import { IMatches } from '../../lib/fuzzy-find' import { IAheadBehind } from '../../models/branch' import classNames from 'classnames' import { createObservableRef } from '../lib/observable-ref' +import { Tooltip } from '../lib/tooltip' +import { enableAccessibleListToolTips } from '../../lib/feature-flag' +import { TooltippedContent } from '../lib/tooltipped-content' interface IRepositoryListItemProps { readonly repository: Repositoryish @@ -27,10 +30,7 @@ interface IRepositoryListItemProps { } /** A repository item. */ -export class RepositoryListItem extends React.Component< - IRepositoryListItemProps, - {} -> { +export class RepositoryListItem extends React.Component { private readonly listItemRef = createObservableRef() public render() { @@ -53,6 +53,13 @@ export class RepositoryListItem extends React.Component< return (
+ + {this.renderTooltip()} + + +
+ {realName} + {alias && <> ({alias})} +
+
{repo.path}
+ + ) + } + public shouldComponentUpdate(nextProps: IRepositoryListItemProps): boolean { if ( nextProps.repository instanceof Repository && @@ -108,19 +132,35 @@ const renderAheadBehindIndicator = (aheadBehind: IAheadBehind) => { return null } + const aheadBehindTooltip = + 'The currently checked out branch is' + + (behind ? ` ${commitGrammar(behind)} behind ` : '') + + (behind && ahead ? 'and' : '') + + (ahead ? ` ${commitGrammar(ahead)} ahead of ` : '') + + 'its tracked branch.' + return ( -
+ {ahead > 0 && } {behind > 0 && } -
+ ) } const renderChangesIndicator = () => { return ( - + - + ) } From 9ccbd6b5cc5aace4925cf1d2b5c7861786769e7d Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 2 Sep 2025 09:31:28 -0400 Subject: [PATCH 030/865] Fix tooltip accessibility logic in commit list Corrects the logic for enabling tooltips based on accessibility settings in commit-list-item and relative time components. Also refines the tooltip disabled check to explicitly compare against true. --- app/src/ui/history/commit-list-item.tsx | 4 ++-- app/src/ui/lib/tooltip.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/ui/history/commit-list-item.tsx b/app/src/ui/history/commit-list-item.tsx index 301b4ed9842..d2586881cc3 100644 --- a/app/src/ui/history/commit-list-item.tsx +++ b/app/src/ui/history/commit-list-item.tsx @@ -161,7 +161,7 @@ export class CommitListItem extends React.PureComponent<
{` • `} - + ) } diff --git a/app/src/ui/lib/tooltip.tsx b/app/src/ui/lib/tooltip.tsx index f4417bf93d9..fac0dae1a1e 100644 --- a/app/src/ui/lib/tooltip.tsx +++ b/app/src/ui/lib/tooltip.tsx @@ -531,7 +531,7 @@ export class Tooltip extends React.Component< } private beginShowTooltip() { - if (this.props.disabled) { + if (this.props.disabled === true) { return } From b6cbc5eb7ab09941d572a8724f8097c8ca41f654 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 2 Sep 2025 09:38:59 -0400 Subject: [PATCH 031/865] Lint:fix --- app/src/ui/repositories-list/repository-list-item.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/ui/repositories-list/repository-list-item.tsx b/app/src/ui/repositories-list/repository-list-item.tsx index a0419390c7b..54cbe675bbc 100644 --- a/app/src/ui/repositories-list/repository-list-item.tsx +++ b/app/src/ui/repositories-list/repository-list-item.tsx @@ -30,7 +30,10 @@ interface IRepositoryListItemProps { } /** A repository item. */ -export class RepositoryListItem extends React.Component { +export class RepositoryListItem extends React.Component< + IRepositoryListItemProps, + {} +> { private readonly listItemRef = createObservableRef() public render() { From 4b6ef90a6c5eafe260e8933145cb9274083f483e Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:22:41 -0400 Subject: [PATCH 032/865] Release 3.5.3-beta2 --- app/package.json | 2 +- changelog.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/package.json b/app/package.json index 61548066fd1..5484e8650c9 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.5.3-beta1", + "version": "3.5.3-beta2", "main": "./main.js", "repository": { "type": "git", diff --git a/changelog.json b/changelog.json index d6eeedfe246..63577d30beb 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,8 @@ { "releases": { + "3.5.3-beta2": [ + "[Improved] The text color of the \"File does not exist“ merge conflict warning meets 4.5:1 contrast requirements - #20902" + ], "3.5.3-beta1": [ "[Improved] Provides the tooltips for list items in a single condensed tooltip that allows keyboard users and screen reader users access upon navigation of a list item - #20804", "[Fixed] Focus lands on first interactive control instead of 'Continue' button in the conflict resolution dialog - #20880" From d57d5ab907a4bde673dbecf72a0192064d460a6f Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:29:31 -0400 Subject: [PATCH 033/865] Apply suggestion from @tidy-dev --- changelog.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.json b/changelog.json index 63577d30beb..bc18413ff01 100644 --- a/changelog.json +++ b/changelog.json @@ -1,7 +1,7 @@ { "releases": { "3.5.3-beta2": [ - "[Improved] The text color of the \"File does not exist“ merge conflict warning meets 4.5:1 contrast requirements - #20902" + "[Improved] The text color of the 'File does not exist' merge conflict warning meets 4.5:1 contrast requirements - #20902" ], "3.5.3-beta1": [ "[Improved] Provides the tooltips for list items in a single condensed tooltip that allows keyboard users and screen reader users access upon navigation of a list item - #20804", From 94238a5b21b096b0890b35daedf1575de73e1c6e Mon Sep 17 00:00:00 2001 From: logonoff Date: Wed, 3 Sep 2025 08:47:15 -0400 Subject: [PATCH 034/865] feat(linux): add Ptyxis shell integration --- app/src/lib/shells/linux.ts | 15 +++++++++++++++ docs/technical/shell-integration.md | 1 + 2 files changed, 16 insertions(+) diff --git a/app/src/lib/shells/linux.ts b/app/src/lib/shells/linux.ts index b6dbb806f92..683313ffd56 100644 --- a/app/src/lib/shells/linux.ts +++ b/app/src/lib/shells/linux.ts @@ -13,6 +13,7 @@ import { export enum Shell { Gnome = 'GNOME Terminal', GnomeConsole = 'GNOME Console', + Ptyxis = 'Ptyxis', Mate = 'MATE Terminal', Tilix = 'Tilix', Terminator = 'Terminator', @@ -46,6 +47,8 @@ function getShellPath(shell: Shell): Promise { return getPathIfAvailable('/usr/bin/gnome-terminal') case Shell.GnomeConsole: return getPathIfAvailable('/usr/bin/kgx') + case Shell.Ptyxis: + return getPathIfAvailable('/usr/bin/ptyxis') case Shell.Mate: return getPathIfAvailable('/usr/bin/mate-terminal') case Shell.Tilix: @@ -87,6 +90,7 @@ export async function getAvailableShells(): Promise< const [ gnomeTerminalPath, gnomeConsolePath, + ptyxisPath, mateTerminalPath, tilixPath, terminatorPath, @@ -105,6 +109,7 @@ export async function getAvailableShells(): Promise< ] = await Promise.all([ getShellPath(Shell.Gnome), getShellPath(Shell.GnomeConsole), + getShellPath(Shell.Ptyxis), getShellPath(Shell.Mate), getShellPath(Shell.Tilix), getShellPath(Shell.Terminator), @@ -131,6 +136,10 @@ export async function getAvailableShells(): Promise< shells.push({ shell: Shell.GnomeConsole, path: gnomeConsolePath }) } + if (ptyxisPath) { + shells.push({ shell: Shell.Ptyxis, path: ptyxisPath }) + } + if (mateTerminalPath) { shells.push({ shell: Shell.Mate, path: mateTerminalPath }) } @@ -208,6 +217,12 @@ export function launch( case Shell.XFCE: case Shell.Alacritty: return spawn(foundShell.path, ['--working-directory', path]) + case Shell.Ptyxis: + return spawn(foundShell.path, [ + '--new-window', + '--working-directory', + path, + ]) case Shell.Urxvt: return spawn(foundShell.path, ['-cd', path]) case Shell.Konsole: diff --git a/docs/technical/shell-integration.md b/docs/technical/shell-integration.md index 29852c997b2..631bcf118ff 100644 --- a/docs/technical/shell-integration.md +++ b/docs/technical/shell-integration.md @@ -235,6 +235,7 @@ The source for the Linux shell integration is found in [`app/src/lib/shells/linu These shells are currently supported: - [GNOME Terminal](https://help.gnome.org/users/gnome-terminal/stable/) + - [Ptyxis](https://gitlab.gnome.org/chergert/ptyxis/) - [MATE Terminal](https://github.com/mate-desktop/mate-terminal) - [Tilix](https://github.com/gnunn1/tilix) - [Terminator](https://gnometerminator.blogspot.com) From e00d6e858b63f9bbb62e47739c185f346e2fd156 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 5 Sep 2025 16:40:05 +0200 Subject: [PATCH 035/865] Only sanitize characters forbidden in the filesystem --- .../ui/add-repository/create-repository.tsx | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/app/src/ui/add-repository/create-repository.tsx b/app/src/ui/add-repository/create-repository.tsx index b04e0f689e0..48bbf168288 100644 --- a/app/src/ui/add-repository/create-repository.tsx +++ b/app/src/ui/add-repository/create-repository.tsx @@ -104,6 +104,22 @@ interface ICreateRepositoryState { readonly readMeExists: boolean } +// We use this instead of sanitizedRepositoryName because it deals with +// valid repository names on GitHub.com but here we only care about whether +// we'll be able to create a directory with the given name. If a user +// creates a repository with a name that GitHub.com doesn't like here it'll +// get sanitized in the Publish dialog later on. +// +// Note that we don't sanitize `\` or `/` here since we use `Path.join` to +// create the full path and that will handle those characters appropriately +// letting users type something like OrgA\RepoB and have the new repo be +// created in the OrgA folder. +// +// macOS and Linux allow are way more allowing so there's no need to sanitize +const safeDirectoryName = (name: string) => { + return __WIN32__ ? name.replace(/[<>:"|?*]/g, '-').replace(/\s+$/, '') : name +} + /** The Create New Repository component. */ export class CreateRepository extends React.Component< ICreateRepositoryProps, @@ -132,7 +148,7 @@ export class CreateRepository extends React.Component< : null const name = this.props.initialPath - ? sanitizedRepositoryName(Path.basename(this.props.initialPath)) + ? safeDirectoryName(Path.basename(this.props.initialPath)) : '' this.state = { @@ -204,7 +220,7 @@ export class CreateRepository extends React.Component< } private async updateIsRepository(path: string, name: string) { - const fullPath = Path.join(path, sanitizedRepositoryName(name)) + const fullPath = Path.join(path, safeDirectoryName(name)) const type = await getRepositoryType(fullPath).catch(e => { log.error(`Unable to determine repository type`, e) @@ -259,7 +275,7 @@ export class CreateRepository extends React.Component< return } - const fullPath = Path.join(path, sanitizedRepositoryName(name), 'README.md') + const fullPath = Path.join(path, safeDirectoryName(name), 'README.md') const readMeExists = await pathExists(fullPath) // Only update readMeExists if the path is still the same @@ -281,7 +297,7 @@ export class CreateRepository extends React.Component< } catch {} } - return Path.join(currentPath, sanitizedRepositoryName(this.state.name)) + return Path.join(currentPath, safeDirectoryName(this.state.name)) } private createRepository = async () => { @@ -455,7 +471,7 @@ export class CreateRepository extends React.Component< } private renderSanitizedName() { - const sanitizedName = sanitizedRepositoryName(this.state.name) + const sanitizedName = safeDirectoryName(this.state.name) if (this.state.name === sanitizedName) { return null } @@ -559,7 +575,7 @@ export class CreateRepository extends React.Component< return null } - const fullPath = Path.join(path, sanitizedRepositoryName(name)) + const fullPath = Path.join(path, safeDirectoryName(name)) return ( @@ -586,7 +602,7 @@ export class CreateRepository extends React.Component< return null } - const fullPath = Path.join(path, sanitizedRepositoryName(name)) + const fullPath = Path.join(path, safeDirectoryName(name)) return ( @@ -639,7 +655,7 @@ export class CreateRepository extends React.Component< return null } - const fullPath = Path.join(path, sanitizedRepositoryName(name)) + const fullPath = Path.join(path, safeDirectoryName(name)) return (
@@ -657,7 +673,7 @@ export class CreateRepository extends React.Component< if (path !== null) { this.props.dispatcher.showPopup({ type: PopupType.AddRepository, - path: Path.join(path, sanitizedRepositoryName(name)), + path: Path.join(path, safeDirectoryName(name)), }) } } From 616dfce10f72100741042e7ce0025eb45c07c8e5 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 5 Sep 2025 16:40:24 +0200 Subject: [PATCH 036/865] Don't allow creating with just spaces --- app/src/ui/add-repository/create-repository.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/ui/add-repository/create-repository.tsx b/app/src/ui/add-repository/create-repository.tsx index 48bbf168288..c372e2aad3f 100644 --- a/app/src/ui/add-repository/create-repository.tsx +++ b/app/src/ui/add-repository/create-repository.tsx @@ -10,7 +10,6 @@ import { getRepositoryType, RepositoryType, } from '../../lib/git' -import { sanitizedRepositoryName } from './sanitized-repository-name' import { TextBox } from '../lib/text-box' import { Button } from '../lib/button' import { Row } from '../lib/row' @@ -651,7 +650,12 @@ export class CreateRepository extends React.Component< private renderPathMessage = () => { const { path, name, isRepository } = this.state - if (path === null || path === '' || name === '' || isRepository) { + if ( + path === null || + path.trim().length === 0 || + name.trim().length === 0 || + isRepository + ) { return null } @@ -682,7 +686,7 @@ export class CreateRepository extends React.Component< const disabled = this.state.path === null || this.state.path.length === 0 || - this.state.name.length === 0 || + this.state.name.trim().length === 0 || this.state.creating || this.state.isRepository From b8b5c8e63c1db1f970feab203cda7d657400c710 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:20:37 -0400 Subject: [PATCH 037/865] Improve accessibility for commit message generation Adds AriaLiveContainer to provide live region updates when generating commit messages, enhancing accessibility for screen reader users. Also updates ariaLabel to reflect generation status. --- app/src/ui/changes/commit-message.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index 4dcbfda0dc2..68815f043dc 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -63,6 +63,7 @@ import { useRepoRulesLogic } from '../../lib/helpers/repo-rules' import { isDotCom } from '../../lib/endpoint-capabilities' import { WorkingDirectoryFileChange } from '../../models/status' import { enableCommitMessageGeneration } from '../../lib/feature-flag' +import { AriaLiveContainer } from '../accessibility/aria-live-container' const addAuthorIcon: OcticonSymbolVariant = { w: 18, @@ -906,11 +907,12 @@ export class CommitMessage extends React.Component< const noFilesSelected = filesSelected.length === 0 const noChangesAvailable = !commitToAmend && noFilesSelected - const ariaLabel = - 'Generate commit message with Copilot' + - (noChangesAvailable - ? '. Files must be selected to generate a commit message.' - : '') + const ariaLabel = isGeneratingCommitMessage + ? "Generating commit details…'" + : 'Generate commit message with Copilot' + + (noChangesAvailable + ? '. Files must be selected to generate a commit message.' + : '') return ( <> @@ -926,6 +928,11 @@ export class CommitMessage extends React.Component< noChangesAvailable } > + {shouldShowGenerateCommitMessageCallOut && ( New From 45a00c8dbdc7318286b9846a490c404d5b70d2d8 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Tue, 16 Sep 2025 10:35:51 +0200 Subject: [PATCH 038/865] Bump Electron to v38.1.0 --- package.json | 2 +- yarn.lock | 17 +++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 798fdc51186..eac6bc3d385 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,7 @@ "@types/webpack-bundle-analyzer": "^4.7.0", "@types/webpack-hot-middleware": "^2.25.9", "diff": "^7.0.0", - "electron": "36.1.0", + "electron": "38.1.0", "electron-packager": "^17.1.1", "electron-winstaller": "^5.0.0", "eslint-plugin-github": "^5.1.5", diff --git a/yarn.lock b/yarn.lock index f7eb4271e68..37104e5ec30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2730,10 +2730,10 @@ electron-winstaller@*, electron-winstaller@^5.0.0: lodash.template "^4.2.2" temp "^0.9.0" -electron@36.1.0: - version "36.1.0" - resolved "https://registry.yarnpkg.com/electron/-/electron-36.1.0.tgz#9919b77e61cd1400acc6dd24f9db8451fba5f8eb" - integrity sha512-gnp3BnbKdGsVc7cm1qlEaZc8pJsR08mIs8H/yTo8gHEtFkGGJbDTVZOYNAfbQlL0aXh+ozv+CnyiNeDNkT1Upg== +electron@38.1.0: + version "38.1.0" + resolved "https://registry.yarnpkg.com/electron/-/electron-38.1.0.tgz#caebed7f3d7b1d43a9d01811db1f62744a753aa4" + integrity sha512-ypA8GF8RU4HD5pA1sa0/2U8k+92EPP2c7pX+3XbgB760F7OmqrFXtYkOilVw6HfV4+lk88XxqigmsUKTACQYoQ== dependencies: "@electron/get" "^2.0.0" "@types/node" "^22.7.7" @@ -6305,7 +6305,7 @@ string.prototype.trimstart@^1.0.7: define-properties "^1.2.0" es-abstract "^1.22.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6319,13 +6319,6 @@ strip-ansi@^3.0.0: dependencies: ansi-regex "^2.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" From 005610f4ade2f0e49e3e7bb6bdce85d0247f04e5 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Tue, 16 Sep 2025 10:36:48 +0200 Subject: [PATCH 039/865] Bump node to v22.19.0 --- .github/workflows/ci.yml | 2 +- .node-version | 2 +- .nvmrc | 2 +- .tool-versions | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba644b5afb5..50c0ff2305d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ on: APPLE_APPLICATION_CERT_PASSWORD: env: - NODE_VERSION: 22.14.0 + NODE_VERSION: 22.19.0 jobs: lint: diff --git a/.node-version b/.node-version index 7d41c735d71..e2228113dd0 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22.14.0 +22.19.0 diff --git a/.nvmrc b/.nvmrc index 517f38666b4..2c6984e9467 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v22.14.0 +v22.19.0 diff --git a/.tool-versions b/.tool-versions index 175418896e3..8b85185889b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ python 3.9.5 -nodejs 22.14.0 +nodejs 22.19.0 From e9ce28a2d087ea686fccc237f407af1c06ce2c86 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Wed, 17 Sep 2025 09:25:33 +0200 Subject: [PATCH 040/865] Bump changelog and version to v3.5.3-beta3 --- app/package.json | 2 +- changelog.json | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/package.json b/app/package.json index 5484e8650c9..c1019b07e75 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.5.3-beta2", + "version": "3.5.3-beta3", "main": "./main.js", "repository": { "type": "git", diff --git a/changelog.json b/changelog.json index bc18413ff01..ac23cc160ff 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,11 @@ { "releases": { + "3.5.3-beta3": [ + "[Fixed] Copilot message generation in progress message is announced to screen readers - #21008", + "[Fixed] Add Ptyxis shell integration - #20963. Thanks @logonoff!", + "[Fixed] Fix: Improve spacing between graphic and text - #7500. Thanks @robbierotman!", + "[Improved] Upgrade Electron to v38.1.0 - #21012" + ], "3.5.3-beta2": [ "[Improved] The text color of the 'File does not exist' merge conflict warning meets 4.5:1 contrast requirements - #20902" ], From 1e6fd2f72637579cd859ca9216c3cd93a418fc07 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Wed, 17 Sep 2025 09:59:38 +0200 Subject: [PATCH 041/865] Update beta Electron version to 38.1.0 Changed the valid Electron version for the beta channel from 36.1.0 to 38.1.0 to reflect the latest release. --- script/validate-electron-version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/validate-electron-version.ts b/script/validate-electron-version.ts index 4e3e26b1e3a..3c0d1aecae0 100644 --- a/script/validate-electron-version.ts +++ b/script/validate-electron-version.ts @@ -19,7 +19,7 @@ type ChannelToValidate = 'production' | 'beta' */ const ValidElectronVersions: Record = { production: '36.1.0', - beta: '36.1.0', + beta: '38.1.0', } // Only when we get a RELEASE_CHANNEL we know we're in the middle of a deployment. From d05580507a8811120eec368558e27ca959ccb8eb Mon Sep 17 00:00:00 2001 From: Ilyasse Date: Fri, 19 Sep 2025 14:01:58 +0100 Subject: [PATCH 042/865] Add prompt to confirm commit message override Add a new user preference to control whether a confirmation dialog appears before overriding a manually entered commit message with a generated one. --- app/src/lib/app-state.ts | 3 ++ app/src/lib/stores/app-store.ts | 28 ++++++++++++ app/src/ui/app.tsx | 3 ++ app/src/ui/dispatcher/dispatcher.ts | 4 ++ ...nerate-commit-message-override-warning.tsx | 45 ++++++++++++++++--- app/src/ui/preferences/preferences.tsx | 17 +++++++ app/src/ui/preferences/prompts.tsx | 22 +++++++++ 7 files changed, 117 insertions(+), 5 deletions(-) diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 70a339a157d..1ea5a6d160a 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -236,6 +236,9 @@ export interface IAppState { /** Should the app prompt the user to confirm they want to commit with changes are hidden by filter? */ readonly askForConfirmationOnCommitFilteredChanges: boolean + /** Should the app prompt the user to confirm commit message override? */ + readonly askForConfirmationOnCommitMessageOverride: boolean + /** How the app should handle uncommitted changes when switching branches */ readonly uncommittedChangesStrategy: UncommittedChangesStrategy diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 69598dedc8e..7180325c3fd 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -387,6 +387,7 @@ const confirmCheckoutCommitDefault: boolean = true const askForConfirmationOnForcePushDefault = true const confirmUndoCommitDefault: boolean = true const confirmCommitFilteredChangesDefault: boolean = true +const confirmCommitMessageOverrideDefault: boolean = true const askToMoveToApplicationsFolderKey: string = 'askToMoveToApplicationsFolder' const confirmRepoRemovalKey: string = 'confirmRepoRemoval' const showCommitLengthWarningKey: string = 'showCommitLengthWarning' @@ -399,6 +400,7 @@ const confirmForcePushKey: string = 'confirmForcePush' const confirmUndoCommitKey: string = 'confirmUndoCommit' const confirmCommitFilteredChangesKey: string = 'confirmCommitFilteredChangesKey' +const confirmCommitMessageOverrideKey: string = 'confirmCommitMessageOverride' const uncommittedChangesStrategyKey = 'uncommittedChangesStrategyKind' @@ -539,6 +541,8 @@ export class AppStore extends TypedBaseStore { private confirmUndoCommit: boolean = confirmUndoCommitDefault private confirmCommitFilteredChanges: boolean = confirmCommitFilteredChangesDefault + private confirmCommitMessageOverride: boolean = + confirmCommitMessageOverrideDefault private imageDiffType: ImageDiffType = imageDiffTypeDefault private hideWhitespaceInChangesDiff: boolean = hideWhitespaceInChangesDiffDefault @@ -1073,6 +1077,8 @@ export class AppStore extends TypedBaseStore { askForConfirmationOnUndoCommit: this.confirmUndoCommit, askForConfirmationOnCommitFilteredChanges: this.confirmCommitFilteredChanges, + askForConfirmationOnCommitMessageOverride: + this.confirmCommitMessageOverride, uncommittedChangesStrategy: this.uncommittedChangesStrategy, selectedExternalEditor: this.selectedExternalEditor, imageDiffType: this.imageDiffType, @@ -2254,6 +2260,11 @@ export class AppStore extends TypedBaseStore { confirmCommitFilteredChangesDefault ) + this.confirmCommitMessageOverride = getBoolean( + confirmCommitMessageOverrideKey, + confirmCommitMessageOverrideDefault + ) + this.uncommittedChangesStrategy = getEnum(uncommittedChangesStrategyKey, UncommittedChangesStrategy) ?? defaultUncommittedChangesStrategy @@ -5444,6 +5455,12 @@ export class AppStore extends TypedBaseStore { repository: Repository, filesSelected: ReadonlyArray ): Promise { + if (!this.confirmCommitMessageOverride) { + // If user has disabled the confirmation, directly generate commit message + await this._generateCommitMessage(repository, filesSelected) + return + } + return this._showPopup({ type: PopupType.GenerateCommitMessageOverrideWarning, repository, @@ -5980,6 +5997,17 @@ export class AppStore extends TypedBaseStore { return Promise.resolve() } + public _setConfirmCommitMessageOverrideSetting( + value: boolean + ): Promise { + this.confirmCommitMessageOverride = value + setBoolean(confirmCommitMessageOverrideKey, value) + + this.emitUpdate() + + return Promise.resolve() + } + public _setUncommittedChangesStrategySetting( value: UncommittedChangesStrategy ): Promise { diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 520f0d45a88..c410163ccf2 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -1579,6 +1579,9 @@ export class App extends React.Component { askForConfirmationOnCommitFilteredChanges={ this.state.askForConfirmationOnCommitFilteredChanges } + confirmCommitMessageOverride={ + this.state.askForConfirmationOnCommitMessageOverride + } uncommittedChangesStrategy={this.state.uncommittedChangesStrategy} selectedExternalEditor={this.state.selectedExternalEditor} useWindowsOpenSSH={this.state.useWindowsOpenSSH} diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 2a4671c45f0..e1c17b39cab 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -2466,6 +2466,10 @@ export class Dispatcher { return this.appStore._setConfirmCommitFilteredChanges(value) } + public setConfirmCommitMessageOverrideSetting(value: boolean) { + return this.appStore._setConfirmCommitMessageOverrideSetting(value) + } + /** * Converts a local repository to use the given fork * as its default remote and associated `GitHubRepository`. diff --git a/app/src/ui/generate-commit-message/generate-commit-message-override-warning.tsx b/app/src/ui/generate-commit-message/generate-commit-message-override-warning.tsx index 06ef2ce2d6e..71f7b5b8b3a 100644 --- a/app/src/ui/generate-commit-message/generate-commit-message-override-warning.tsx +++ b/app/src/ui/generate-commit-message/generate-commit-message-override-warning.tsx @@ -1,4 +1,6 @@ import * as React from 'react' +import { Repository } from '../../models/repository' +import { WorkingDirectoryFileChange } from '../../models/status' import { Dialog, DialogContent, @@ -6,8 +8,8 @@ import { OkCancelButtonGroup, } from '../dialog' import { Dispatcher } from '../dispatcher' -import { Repository } from '../../models/repository' -import { WorkingDirectoryFileChange } from '../../models/status' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { Row } from '../lib/row' interface IGenerateCommitMessageOverrideWarningProps { readonly dispatcher: Dispatcher @@ -20,9 +22,20 @@ interface IGenerateCommitMessageOverrideWarningProps { readonly onDismissed: () => void } -export class GenerateCommitMessageOverrideWarning extends React.Component { +interface IGenerateCommitMessageOverrideWarningState { + readonly confirmCommitMessageOverride: boolean +} + +export class GenerateCommitMessageOverrideWarning extends React.Component< + IGenerateCommitMessageOverrideWarningProps, + IGenerateCommitMessageOverrideWarningState +> { public constructor(props: IGenerateCommitMessageOverrideWarningProps) { super(props) + + this.state = { + confirmCommitMessageOverride: true, + } } public render() { @@ -37,10 +50,21 @@ export class GenerateCommitMessageOverrideWarning extends React.Component -

+ The commit message you have entered will be overridden by the generated commit message. -

+ + + +
@@ -49,7 +73,18 @@ export class GenerateCommitMessageOverrideWarning extends React.Component + ) => { + const value = !event.currentTarget.checked + this.setState({ confirmCommitMessageOverride: value }) + } + private onOverride = async () => { + if (!this.state.confirmCommitMessageOverride) { + await this.props.dispatcher.setConfirmCommitMessageOverrideSetting(false) + } + this.props.dispatcher.generateCommitMessage( this.props.repository, this.props.filesSelected diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index 64e84a957ff..5fadb15e0d8 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -67,6 +67,7 @@ interface IPreferencesProps { readonly confirmForcePush: boolean readonly confirmUndoCommit: boolean readonly askForConfirmationOnCommitFilteredChanges: boolean + readonly confirmCommitMessageOverride: boolean readonly uncommittedChangesStrategy: UncommittedChangesStrategy readonly selectedExternalEditor: string | null readonly selectedShell: Shell @@ -104,6 +105,7 @@ interface IPreferencesState { readonly confirmForcePush: boolean readonly confirmUndoCommit: boolean readonly askForConfirmationOnCommitFilteredChanges: boolean + readonly confirmCommitMessageOverride: boolean readonly uncommittedChangesStrategy: UncommittedChangesStrategy readonly availableEditors: ReadonlyArray readonly useCustomEditor: boolean @@ -179,6 +181,7 @@ export class Preferences extends React.Component< confirmForcePush: false, confirmUndoCommit: false, askForConfirmationOnCommitFilteredChanges: false, + confirmCommitMessageOverride: true, uncommittedChangesStrategy: defaultUncommittedChangesStrategy, selectedExternalEditor: this.props.selectedExternalEditor, availableShells: [], @@ -248,6 +251,7 @@ export class Preferences extends React.Component< confirmUndoCommit: this.props.confirmUndoCommit, askForConfirmationOnCommitFilteredChanges: this.props.askForConfirmationOnCommitFilteredChanges, + confirmCommitMessageOverride: this.props.confirmCommitMessageOverride, uncommittedChangesStrategy: this.props.uncommittedChangesStrategy, availableShells, availableEditors, @@ -482,6 +486,9 @@ export class Preferences extends React.Component< askForConfirmationOnCommitFilteredChanges={ this.state.askForConfirmationOnCommitFilteredChanges } + confirmCommitMessageOverride={ + this.state.confirmCommitMessageOverride + } onConfirmRepositoryRemovalChanged={ this.onConfirmRepositoryRemovalChanged } @@ -496,6 +503,9 @@ export class Preferences extends React.Component< onAskForConfirmationOnCommitFilteredChanges={ this.onAskForConfirmationOnCommitFilteredChanges } + onConfirmCommitMessageOverrideChanged={ + this.onConfirmCommitMessageOverrideChanged + } uncommittedChangesStrategy={this.state.uncommittedChangesStrategy} onUncommittedChangesStrategyChanged={ this.onUncommittedChangesStrategyChanged @@ -620,6 +630,10 @@ export class Preferences extends React.Component< this.setState({ askForConfirmationOnCommitFilteredChanges: value }) } + private onConfirmCommitMessageOverrideChanged = (value: boolean) => { + this.setState({ confirmCommitMessageOverride: value }) + } + private onUncommittedChangesStrategyChanged = ( uncommittedChangesStrategy: UncommittedChangesStrategy ) => { @@ -806,6 +820,9 @@ export class Preferences extends React.Component< await dispatcher.setConfirmCommitFilteredChanges( this.state.askForConfirmationOnCommitFilteredChanges ) + await dispatcher.setConfirmCommitMessageOverrideSetting( + this.state.confirmCommitMessageOverride + ) if (this.state.selectedExternalEditor) { await dispatcher.setExternalEditor(this.state.selectedExternalEditor) diff --git a/app/src/ui/preferences/prompts.tsx b/app/src/ui/preferences/prompts.tsx index a3a64f0e0ac..2acf96deae7 100644 --- a/app/src/ui/preferences/prompts.tsx +++ b/app/src/ui/preferences/prompts.tsx @@ -15,6 +15,7 @@ interface IPromptsPreferencesProps { readonly confirmForcePush: boolean readonly confirmUndoCommit: boolean readonly askForConfirmationOnCommitFilteredChanges: boolean + readonly confirmCommitMessageOverride: boolean readonly showCommitLengthWarning: boolean readonly uncommittedChangesStrategy: UncommittedChangesStrategy readonly onConfirmDiscardChangesChanged: (checked: boolean) => void @@ -29,6 +30,7 @@ interface IPromptsPreferencesProps { value: UncommittedChangesStrategy ) => void readonly onAskForConfirmationOnCommitFilteredChanges: (value: boolean) => void + readonly onConfirmCommitMessageOverrideChanged: (checked: boolean) => void } interface IPromptsPreferencesState { @@ -40,6 +42,7 @@ interface IPromptsPreferencesState { readonly confirmForcePush: boolean readonly confirmUndoCommit: boolean readonly askForConfirmationOnCommitFilteredChanges: boolean + readonly confirmCommitMessageOverride: boolean readonly uncommittedChangesStrategy: UncommittedChangesStrategy } @@ -62,6 +65,7 @@ export class Prompts extends React.Component< uncommittedChangesStrategy: this.props.uncommittedChangesStrategy, askForConfirmationOnCommitFilteredChanges: this.props.askForConfirmationOnCommitFilteredChanges, + confirmCommitMessageOverride: this.props.confirmCommitMessageOverride, } } @@ -128,6 +132,15 @@ export class Prompts extends React.Component< this.props.onAskForConfirmationOnCommitFilteredChanges(value) } + private onConfirmCommitMessageOverrideChanged = ( + event: React.FormEvent + ) => { + const value = event.currentTarget.checked + + this.setState({ confirmCommitMessageOverride: value }) + this.props.onConfirmCommitMessageOverrideChanged(value) + } + private onConfirmRepositoryRemovalChanged = ( event: React.FormEvent ) => { @@ -280,6 +293,15 @@ export class Prompts extends React.Component< } onChange={this.onConfirmUndoCommitChanged} /> + {this.renderCommittingFilteredChangesPrompt()}
From 510301c56385521b5e3bcb67094f7c1a4f7ba660 Mon Sep 17 00:00:00 2001 From: zeki <153834382+zekariasasaminew@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:29:15 -0500 Subject: [PATCH 043/865] Add Copilot commit message generation to context menu --- app/src/ui/changes/commit-message.tsx | 61 ++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index 68815f043dc..93a04ea34e5 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -825,6 +825,44 @@ export class CommitMessage extends React.Component< } } + private getGenerateCommitMessageMenuItem(): IMenuItem | null { + const { + accounts, + onGenerateCommitMessage, + filesSelected, + isCommitting, + isGeneratingCommitMessage, + commitToAmend, + } = this.props + + if ( + !accounts.some(enableCommitMessageGeneration) || + onGenerateCommitMessage === undefined + ) { + return null + } + + const noFilesSelected = filesSelected.length === 0 + const noChangesAvailable = !commitToAmend && noFilesSelected + + return { + label: __DARWIN__ + ? 'Generate Commit Message with Copilot' + : 'Generate commit message with Copilot', + action: () => { + const { commitMessage } = this.state + onGenerateCommitMessage( + filesSelected, + !!commitMessage.summary || !!commitMessage.description + ) + }, + enabled: + isCommitting !== true && + !isGeneratingCommitMessage && + !noChangesAvailable, + } + } + private onContextMenu = (event: React.MouseEvent) => { if ( event.target instanceof HTMLTextAreaElement || @@ -833,16 +871,29 @@ export class CommitMessage extends React.Component< return } - showContextualMenu([this.getAddRemoveCoAuthorsMenuItem()]) + const items: IMenuItem[] = [this.getAddRemoveCoAuthorsMenuItem()] + + const generateMenuItem = this.getGenerateCommitMessageMenuItem() + if (generateMenuItem) { + items.push(generateMenuItem) + } + + showContextualMenu(items) } private onAutocompletingInputContextMenu = () => { - const items: IMenuItem[] = [ - this.getAddRemoveCoAuthorsMenuItem(), + const items: IMenuItem[] = [this.getAddRemoveCoAuthorsMenuItem()] + + const generateMenuItem = this.getGenerateCommitMessageMenuItem() + if (generateMenuItem) { + items.push(generateMenuItem) + } + + items.push( { type: 'separator' }, { role: 'editMenu' }, - { type: 'separator' }, - ] + { type: 'separator' } + ) items.push( this.getCommitSpellcheckEnabilityMenuItem( From 0e233855018b1c4834c0a826f500cf353fc2e42c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 19:47:49 +0000 Subject: [PATCH 044/865] Bump tar-fs from 2.1.3 to 2.1.4 in /app Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 2.1.3 to 2.1.4. - [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.3...v2.1.4) --- updated-dependencies: - dependency-name: tar-fs dependency-version: 2.1.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- app/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/yarn.lock b/app/yarn.lock index db6b072be7b..e8ceb3d72d4 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -1337,9 +1337,9 @@ tabbable@^5.1.0: integrity sha512-Y3nSukchqM5UchuZjhj/WyE79Qb4RM/Vx3x3oHO3UYKKpf70Hy3iVRxb61MzCavN74aZsKzvPl4KNG8tQUAjFQ== tar-fs@^2.0.0: - version "2.1.3" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.3.tgz#fb3b8843a26b6f13a08e606f7922875eb1fbbf92" - integrity sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg== + version "2.1.4" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" + integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== dependencies: chownr "^1.1.1" mkdirp-classic "^0.5.2" From 85e4205364870bb39defbe503f084948b9fae1d8 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Mon, 29 Sep 2025 15:06:11 +0200 Subject: [PATCH 045/865] Upgrade Electron to version 38.2.0 Updated the Electron dependency from 38.1.0 to 38.2.0 in package.json and yarn.lock to incorporate the latest fixes and improvements. --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index eac6bc3d385..f03fa1d8483 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,7 @@ "@types/webpack-bundle-analyzer": "^4.7.0", "@types/webpack-hot-middleware": "^2.25.9", "diff": "^7.0.0", - "electron": "38.1.0", + "electron": "38.2.0", "electron-packager": "^17.1.1", "electron-winstaller": "^5.0.0", "eslint-plugin-github": "^5.1.5", diff --git a/yarn.lock b/yarn.lock index 37104e5ec30..02fc6d45d52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2730,10 +2730,10 @@ electron-winstaller@*, electron-winstaller@^5.0.0: lodash.template "^4.2.2" temp "^0.9.0" -electron@38.1.0: - version "38.1.0" - resolved "https://registry.yarnpkg.com/electron/-/electron-38.1.0.tgz#caebed7f3d7b1d43a9d01811db1f62744a753aa4" - integrity sha512-ypA8GF8RU4HD5pA1sa0/2U8k+92EPP2c7pX+3XbgB760F7OmqrFXtYkOilVw6HfV4+lk88XxqigmsUKTACQYoQ== +electron@38.2.0: + version "38.2.0" + resolved "https://registry.yarnpkg.com/electron/-/electron-38.2.0.tgz#fc6bb321923320cc76aa036c7677642ab0239d70" + integrity sha512-Cw5Mb+N5NxsG0Hc1qr8I65Kt5APRrbgTtEEn3zTod30UNJRnAE1xbGk/1NOaDn3ODzI/MYn6BzT9T9zreP7xWA== dependencies: "@electron/get" "^2.0.0" "@types/node" "^22.7.7" From 9830713e4b138037b1f7c33c4559ee6c3351da1a Mon Sep 17 00:00:00 2001 From: Neel Bhalla Date: Mon, 29 Sep 2025 23:22:18 -0400 Subject: [PATCH 046/865] feat: pull from paginated local vs head-n based on last commit --- app/src/lib/stores/app-store.ts | 22 ++++++++++++++++++--- app/src/lib/stores/git-store.ts | 34 +++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 69598dedc8e..505ff85136d 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -1717,13 +1717,29 @@ export class AppStore extends TypedBaseStore { if (formState.kind === HistoryTabMode.History) { const commits = state.compareState.commitSHAs - const newCommits = await gitStore.loadCommitBatch('HEAD', commits.length) - if (newCommits == null) { + const tip = state.branchesState.tip + + let newCommits: string[] | null = null + + // Prioritize pulling from the local commits if the last one we pulled is local + if ( + commits.length > 0 && + tip.kind === TipState.Valid && + gitStore.localCommitSHAs.includes(commits[commits.length - 1]) + ) { + newCommits = await gitStore.loadLocalCommits(tip.branch, commits.length) + } + + if (!newCommits || newCommits.length === 0) { + newCommits = await gitStore.loadCommitBatch('HEAD', commits.length) + } + + if (!newCommits) { return } this.repositoryStateCache.updateCompareState(repository, () => ({ - commitSHAs: commits.concat(newCommits), + commitSHAs: commits.concat(newCommits ?? []), })) this.emitUpdate() } diff --git a/app/src/lib/stores/git-store.ts b/app/src/lib/stores/git-store.ts index fb13690856d..4c669e55baf 100644 --- a/app/src/lib/stores/git-store.ts +++ b/app/src/lib/stores/git-store.ts @@ -597,26 +597,31 @@ export class GitStore extends BaseStore { * Load local commits into memory for the current repository. * * @param branch The branch to query for unpublished commits. + * @param skip The amount of commits to skip to support pagination loading of local commits. If skip is undefined, + * this will reset the local commits cache and treat it as a pagination reset. * * If the tip of the repository does not have commits (i.e. is unborn), this * should be invoked with `null`, which clears any existing commits from the * store. */ - public async loadLocalCommits(branch: Branch | null): Promise { + public async loadLocalCommits( + branch: Branch | null, + skip?: number + ): Promise { if (branch === null) { this._localCommitSHAs = [] - return + return null } let localCommits: ReadonlyArray | undefined if (branch.upstream) { const range = revRange(branch.upstream, branch.name) localCommits = await this.performFailableOperation(() => - getCommits(this.repository, range, CommitBatchSize) + getCommits(this.repository, range, CommitBatchSize, skip) ) } else { localCommits = await this.performFailableOperation(() => - getCommits(this.repository, 'HEAD', CommitBatchSize, undefined, [ + getCommits(this.repository, 'HEAD', CommitBatchSize, skip, [ '--not', '--remotes', ]) @@ -624,12 +629,29 @@ export class GitStore extends BaseStore { } if (!localCommits) { - return + return null } this.storeCommits(localCommits) - this._localCommitSHAs = localCommits.map(c => c.sha) + + let newCommitSHAs: string[] + + if (skip !== undefined) { + // perform a soft ammend to the list of local commits + const previousSHAs = new Set(this._localCommitSHAs) + newCommitSHAs = localCommits + .map(c => c.sha) + .filter(sha => !previousSHAs.has(sha)) + this._localCommitSHAs = [...this._localCommitSHAs, ...newCommitSHAs] + } else { + // reset the local commits since its a page reset + newCommitSHAs = localCommits.map(c => c.sha) + this._localCommitSHAs = Array.from(newCommitSHAs) + } + this.emitUpdate() + + return newCommitSHAs } /** From 18031f8325750c07a41fd2d7303d90f562735118 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 30 Sep 2025 09:07:03 -0400 Subject: [PATCH 047/865] Release 3.5.3-beta4 --- app/package.json | 2 +- changelog.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/package.json b/app/package.json index c1019b07e75..2b70ab778fd 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.5.3-beta3", + "version": "3.5.3-beta4", "main": "./main.js", "repository": { "type": "git", diff --git a/changelog.json b/changelog.json index ac23cc160ff..fe6a87c9b1e 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,6 @@ { "releases": { + "3.5.3-beta4": ["[Improved] Upgrade Electron to v38.2.0 - #21060"], "3.5.3-beta3": [ "[Fixed] Copilot message generation in progress message is announced to screen readers - #21008", "[Fixed] Add Ptyxis shell integration - #20963. Thanks @logonoff!", From caebf6d843db5f4b7930c015d137b891ee7ccafd Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 30 Sep 2025 09:41:50 -0400 Subject: [PATCH 048/865] Update validate-electron-version.ts --- script/validate-electron-version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/validate-electron-version.ts b/script/validate-electron-version.ts index 3c0d1aecae0..fe9d8f79ff1 100644 --- a/script/validate-electron-version.ts +++ b/script/validate-electron-version.ts @@ -19,7 +19,7 @@ type ChannelToValidate = 'production' | 'beta' */ const ValidElectronVersions: Record = { production: '36.1.0', - beta: '38.1.0', + beta: '38.2.0', } // Only when we get a RELEASE_CHANNEL we know we're in the middle of a deployment. From 510179b43ec0d3b6b00f6974c10dae071c915df6 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Tue, 7 Oct 2025 14:26:10 +0200 Subject: [PATCH 049/865] Bump changelog and version to v3.5.3 --- app/package.json | 2 +- changelog.json | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/package.json b/app/package.json index 2b70ab778fd..fd64a2c1ea9 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.5.3-beta4", + "version": "3.5.3", "main": "./main.js", "repository": { "type": "git", diff --git a/changelog.json b/changelog.json index fe6a87c9b1e..bb4089e3154 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,14 @@ { "releases": { + "3.5.3": [ + "[Added] Add Ptyxis shell integration - #20963. Thanks @logonoff!", + "[Fixed] Copilot message generation in progress message is announced to screen readers - #21008", + "[Fixed] Fix: Improve spacing between graphic and text - #7500. Thanks @robbierotman!", + "[Fixed] Focus lands on first interactive control instead of 'Continue' button in the conflict resolution dialog - #20880", + "[Improved] Upgrade Electron to v38.2.0 - #21060", + "[Improved] The text color of the 'File does not exist' merge conflict warning meets 4.5:1 contrast requirements - #20902", + "[Improved] Provides the tooltips for list items in a single condensed tooltip that allows keyboard users and screen reader users access upon navigation of a list item - #20804" + ], "3.5.3-beta4": ["[Improved] Upgrade Electron to v38.2.0 - #21060"], "3.5.3-beta3": [ "[Fixed] Copilot message generation in progress message is announced to screen readers - #21008", From 35988e37d3e8284aa1df743dc4e69dd60aa8a620 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Tue, 7 Oct 2025 14:26:23 +0200 Subject: [PATCH 050/865] Update production Electron version to 38.2.0 Bumps the Electron version for the production channel from 36.1.0 to 38.2.0 to align with the beta channel and ensure compatibility with the latest Electron features and fixes. --- script/validate-electron-version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/validate-electron-version.ts b/script/validate-electron-version.ts index fe9d8f79ff1..d124c7a095e 100644 --- a/script/validate-electron-version.ts +++ b/script/validate-electron-version.ts @@ -18,7 +18,7 @@ type ChannelToValidate = 'production' | 'beta' * to a previous version of GitHub Desktop without losing all settings. */ const ValidElectronVersions: Record = { - production: '36.1.0', + production: '38.2.0', beta: '38.2.0', } From 849ad0174b8e568ed342c7e5f6bada062230b838 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Wed, 8 Oct 2025 10:49:03 +0200 Subject: [PATCH 051/865] =?UTF-8?q?Update=20Electron=20target=20version=20?= =?UTF-8?q?in=20.npmrc=20file=20too=20=F0=9F=98=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.npmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/.npmrc b/app/.npmrc index 6b1f40f4d4e..921785a5c71 100644 --- a/app/.npmrc +++ b/app/.npmrc @@ -1,3 +1,3 @@ runtime = electron disturl = https://electronjs.org/headers -target = 36.1.0 +target = 38.2.0 From 34fe61047eedc03c8ff1c4bd22d40d90b5b090be Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Thu, 9 Oct 2025 14:51:55 +0200 Subject: [PATCH 052/865] Add accent-color CSS variable and apply to body Introduces a new --accent-color CSS variable in both default and dark themes, and applies it to the body element using the accent-color property. This enables consistent accent coloring for form controls and other UI elements. --- app/styles/_globals.scss | 1 + app/styles/_variables.scss | 1 + app/styles/themes/_dark.scss | 1 + 3 files changed, 3 insertions(+) diff --git a/app/styles/_globals.scss b/app/styles/_globals.scss index 827f78a7b79..7e9bf45f046 100644 --- a/app/styles/_globals.scss +++ b/app/styles/_globals.scss @@ -56,6 +56,7 @@ body { color: var(--text-color); background-color: var(--background-color); + accent-color: var(--accent-color); } :not(input, textarea) { diff --git a/app/styles/_variables.scss b/app/styles/_variables.scss index 1207787723a..d48b4c14b5a 100644 --- a/app/styles/_variables.scss +++ b/app/styles/_variables.scss @@ -367,6 +367,7 @@ $overlay-background-color: rgba(0, 0, 0, 0.4); /** The highlight color used for focus rings and focus box shadows */ --focus-color: #{$blue}; + --accent-color: #{$blue-400}; --diff-linenumber-focus-color: #{$blue-600}; /** diff --git a/app/styles/themes/_dark.scss b/app/styles/themes/_dark.scss index a03d76ddab0..f3fbfd8e18a 100644 --- a/app/styles/themes/_dark.scss +++ b/app/styles/themes/_dark.scss @@ -273,6 +273,7 @@ body.theme-dark { /** The highlight color used for focus rings and focus box shadows */ --focus-color: #{$blue}; + --accent-color: #{$blue-200}; --diff-linenumber-focus-color: #{$blue-200}; /** From d2a2d3e46fe56b863a33d39d2aba2972ba781f4a Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Thu, 9 Oct 2025 15:37:10 +0200 Subject: [PATCH 053/865] Update macOS support check for Changed the minimum supported macOS version from 11.0 to 12.0 for Electron compatibility checks. --- app/src/lib/get-os.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/get-os.ts b/app/src/lib/get-os.ts index b442774754f..50f548b43c7 100644 --- a/app/src/lib/get-os.ts +++ b/app/src/lib/get-os.ts @@ -94,7 +94,7 @@ export const isWindowsAndNoLongerSupportedByElectron = memoizeOne( ) export const isMacOSAndNoLongerSupportedByElectron = memoizeOne( - () => __DARWIN__ && systemVersionLessThan('11.0') + () => __DARWIN__ && systemVersionLessThan('12.0') ) export const isOSNoLongerSupportedByElectron = memoizeOne( From c17f42ba1aaa67472786781a89634111745fddac Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Thu, 9 Oct 2025 15:40:43 +0200 Subject: [PATCH 054/865] Update changelog to note removal of macOS 11 support Added an entry to the changelog indicating that support for macOS 11 has been removed as part of issue #21060. --- changelog.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog.json b/changelog.json index bb4089e3154..5cc06885a96 100644 --- a/changelog.json +++ b/changelog.json @@ -7,7 +7,8 @@ "[Fixed] Focus lands on first interactive control instead of 'Continue' button in the conflict resolution dialog - #20880", "[Improved] Upgrade Electron to v38.2.0 - #21060", "[Improved] The text color of the 'File does not exist' merge conflict warning meets 4.5:1 contrast requirements - #20902", - "[Improved] Provides the tooltips for list items in a single condensed tooltip that allows keyboard users and screen reader users access upon navigation of a list item - #20804" + "[Improved] Provides the tooltips for list items in a single condensed tooltip that allows keyboard users and screen reader users access upon navigation of a list item - #20804", + "[Removed] Remove support for macOS 11 - #21060" ], "3.5.3-beta4": ["[Improved] Upgrade Electron to v38.2.0 - #21060"], "3.5.3-beta3": [ From d7335e4f28f9a2271627b58f0ab980f6e51bbc06 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Thu, 9 Oct 2025 16:51:01 +0200 Subject: [PATCH 055/865] Create validate-macos-version.ts --- script/validate-macos-version.ts | 108 +++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 script/validate-macos-version.ts diff --git a/script/validate-macos-version.ts b/script/validate-macos-version.ts new file mode 100644 index 00000000000..2b2cc2f865c --- /dev/null +++ b/script/validate-macos-version.ts @@ -0,0 +1,108 @@ +/* eslint-disable no-sync */ +/// + +import { readFileSync, existsSync } from 'fs-extra' +import { join } from 'path' + +import * as distInfo from './dist-info' + +type ChannelToValidate = 'production' | 'beta' + +/** + * This object states the valid/expected minimum macOS versions for each publishable + * channel of GitHub Desktop. + * + * The purpose of this is to ensure that we don't accidentally publish a + * production/beta/test build with the wrong minimum macOS version, which could + * cause compatibility issues or prevent users from running the application. + */ +const ValidMacOSVersions: Record = { + production: '12.0', + beta: '12.0', +} + +// Only when we get a RELEASE_CHANNEL we know we're in the middle of a deployment. +// In that case, we want to error out if the macOS version is not what we expect. +const errorOnMismatch = (process.env.RELEASE_CHANNEL ?? '').length > 0 + +function handleError(message: string): never { + if (errorOnMismatch) { + console.error(message) + process.exit(1) + } else { + console.warn(message) + process.exit(0) + } +} + +const channel = + process.env.RELEASE_CHANNEL || distInfo.getChannelFromReleaseBranch() + +if (!isChannelToValidate(channel)) { + console.log(`No need to validate the macOS version of a ${channel} build.`) + process.exit(0) +} + +const expectedVersion = ValidMacOSVersions[channel] + +function validateMacOSVersion() { + try { + const actualVersion = resolveVersionInInfoPlist() + + if (actualVersion !== expectedVersion) { + handleError( + `The minimum macOS version for the ${channel} channel is incorrect. Expected ${expectedVersion} but found ${actualVersion}.` + ) + } + + console.log( + `The minimum macOS version for the ${channel} channel is correct: ${actualVersion}.` + ) + } catch (error) { + handleError( + `Failed to validate macOS version: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } +} + +function isChannelToValidate(channel: string): channel is ChannelToValidate { + return Object.keys(ValidMacOSVersions).includes(channel) +} + +function resolveVersionInInfoPlist(): string { + const infoPlistPath = join( + __dirname, + '..', + 'node_modules', + 'electron', + 'dist', + 'Electron.app', + 'Contents', + 'Info.plist' + ) + + if (!existsSync(infoPlistPath)) { + throw new Error( + `Info.plist file not found at ${infoPlistPath}. Make sure Electron is installed.` + ) + } + + const plistContent = readFileSync(infoPlistPath, 'utf-8') + + // Simple regex-based parsing for LSMinimumSystemVersion + // Look for the pattern: LSMinimumSystemVersion\s*version + const versionMatch = plistContent.match( + /LSMinimumSystemVersion<\/key>\s*([^<]+)<\/string>/ + ) + + if (!versionMatch || !versionMatch[1]) { + throw new Error('LSMinimumSystemVersion not found in Info.plist') + } + + return versionMatch[1].trim() +} + +// Run the validation +validateMacOSVersion() From dd3c2070349170afb81ebb74ca6966ec2a9f770d Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Thu, 9 Oct 2025 16:51:45 +0200 Subject: [PATCH 056/865] Add macOS version validation to CI workflow Introduces a new 'validate-macos-version' script and integrates it into the CI pipeline to ensure macOS version requirements are checked during continuous integration. --- .github/workflows/ci.yml | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50c0ff2305d..43e3ddb75fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,7 @@ jobs: cache: yarn - run: yarn - run: yarn validate-electron-version + - run: yarn validate-macos-version - run: yarn lint - run: yarn validate-changelog - name: Ensure a clean working directory diff --git a/package.json b/package.json index f03fa1d8483..3231e638980 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "eslint": "eslint --cache --rulesdir ./eslint-rules \"./eslint-rules/**/*.js\" \"./script/**/*.ts{,x}\" \"./app/{src,typings,test}/**/*.{j,t}s{,x}\" \"./changelog.json\"", "eslint-check": "eslint --print-config .eslintrc.* | eslint-config-prettier-check", "validate-electron-version": "ts-node -P script/tsconfig.json script/validate-electron-version.ts", + "validate-macos-version": "ts-node -P script/tsconfig.json script/validate-macos-version.ts", "clean-slate": "rimraf out node_modules app/node_modules && yarn", "rebuild-hard:dev": "yarn clean-slate && yarn build:dev", "rebuild-hard:prod": "yarn clean-slate && yarn build:prod", From 81910bd79ea44b11e195d3f30107dda611f341d0 Mon Sep 17 00:00:00 2001 From: Mola Adedeji Date: Fri, 17 Oct 2025 13:41:38 -0300 Subject: [PATCH 057/865] Display line change count in PR Preview Dialog --- .../open-pull-request-dialog.tsx | 3 +- .../pull-request-files-changed.tsx | 29 +++++++++++++++---- .../ui/_pull-request-files-changed.scss | 18 +++++++++++- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/app/src/ui/open-pull-request/open-pull-request-dialog.tsx b/app/src/ui/open-pull-request/open-pull-request-dialog.tsx index e6433daf8e4..447c5f5e942 100644 --- a/app/src/ui/open-pull-request/open-pull-request-dialog.tsx +++ b/app/src/ui/open-pull-request/open-pull-request-dialog.tsx @@ -160,7 +160,6 @@ export class OpenPullRequestDialog extends React.Component + /** The changeset data associated with the selected commit */ + readonly changesetData: IChangesetData /** The diff that should be rendered */ readonly diff: IDiff | null @@ -232,7 +233,7 @@ export class PullRequestFilesChanged extends React.Component< } private onRowDoubleClick = (row: number) => { - const files = this.props.files + const files = this.props.changesetData.files const file = files[row] this.props.onOpenInExternalEditor(file.path) @@ -246,6 +247,8 @@ export class PullRequestFilesChanged extends React.Component<
Showing changes from all commits
+ {this.renderLinesChanged()} +
+
+{changesetData.linesAdded}
+
-{changesetData.linesDeleted}
+
+ ) + } + private renderFileList() { - const { files, selectedFile, fileListWidth } = this.props + const { changesetData, selectedFile, fileListWidth } = this.props return ( Date: Mon, 20 Oct 2025 10:58:39 -0400 Subject: [PATCH 058/865] Fix column type for added rows in side-by-side diff Always use DiffColumn.After for added rows instead of conditionally using DiffColumn.Before when side-by-side diffs are disabled. This ensures consistent rendering of added lines in the diff view. --- app/src/ui/diff/side-by-side-diff.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/ui/diff/side-by-side-diff.tsx b/app/src/ui/diff/side-by-side-diff.tsx index 48947369fe6..71810da89eb 100644 --- a/app/src/ui/diff/side-by-side-diff.tsx +++ b/app/src/ui/diff/side-by-side-diff.tsx @@ -2092,8 +2092,7 @@ function* enumerateColumnContents( if (row.type === DiffRowType.Hunk) { yield { type: DiffColumn.Before, content: row.content } } else if (row.type === DiffRowType.Added) { - const type = showSideBySideDiffs ? DiffColumn.After : DiffColumn.Before - yield { type, content: row.data.content } + yield { type: DiffColumn.After, content: row.data.content } } else if (row.type === DiffRowType.Deleted) { yield { type: DiffColumn.Before, content: row.data.content } } else if (row.type === DiffRowType.Context) { From 8dd93b357a075a542efafacbd3acbf20acb23bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berk=20C=CC=A7ebi?= Date: Tue, 21 Oct 2025 13:49:01 +0900 Subject: [PATCH 059/865] Increase title bar height on macOS Tahoe --- app/src/lib/get-os.ts | 5 +++++ app/src/ui/window/title-bar.tsx | 13 ++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/src/lib/get-os.ts b/app/src/lib/get-os.ts index b442774754f..b575c624f31 100644 --- a/app/src/lib/get-os.ts +++ b/app/src/lib/get-os.ts @@ -84,6 +84,11 @@ export const isMacOSBigSurOrLater = memoizeOne( () => __DARWIN__ && systemVersionGreaterThanOrEqualTo('10.16') ) +/** We're currently running macOS and it is at least Tahoe. */ +export const isMacOSTahoeOrLater = memoizeOne( + () => __DARWIN__ && systemVersionGreaterThanOrEqualTo('26') +) + /** We're currently running Windows 10 and it is at least 1809 Preview Build 17666. */ export const isWindows10And1809Preview17666OrLater = memoizeOne( () => __WIN32__ && systemVersionGreaterThanOrEqualTo('10.0.17666') diff --git a/app/src/ui/window/title-bar.tsx b/app/src/ui/window/title-bar.tsx index b6fe9b7c041..461cd4eac70 100644 --- a/app/src/ui/window/title-bar.tsx +++ b/app/src/ui/window/title-bar.tsx @@ -4,7 +4,7 @@ import { WindowState } from '../../lib/window-state' import { WindowControls } from './window-controls' import { Octicon } from '../octicons/octicon' import * as octicons from '../octicons/octicons.generated' -import { isMacOSBigSurOrLater } from '../../lib/get-os' +import { isMacOSBigSurOrLater, isMacOSTahoeOrLater } from '../../lib/get-os' import { getAppleActionOnDoubleClick, isWindowMaximized, @@ -16,8 +16,15 @@ import { /** Get the height (in pixels) of the title bar depending on the platform */ export function getTitleBarHeight() { if (__DARWIN__) { - // Big Sur has taller title bars, see #10980 - return isMacOSBigSurOrLater() ? 26 : 22 + if (isMacOSTahoeOrLater()) { + // Tahoe also has taller title bars, see #21135 + return 32 + } else if (isMacOSBigSurOrLater()) { + // Big Sur has taller title bars, see #10980 + return 26 + } else { + return 22 + } } return 28 From 025abf17141d128c0ce71f98957d819c6a584cc6 Mon Sep 17 00:00:00 2001 From: Mola Adedeji Date: Wed, 22 Oct 2025 13:45:47 -0300 Subject: [PATCH 060/865] Move text to Dialog Header --- .../open-pull-request-dialog.tsx | 13 +++++++-- .../open-pull-request-header.tsx | 16 ++++++++++ .../pull-request-files-changed.tsx | 29 ++++--------------- app/styles/_ui.scss | 1 + app/styles/ui/_open-pull-request-header.scss | 12 ++++++++ .../ui/_pull-request-files-changed.scss | 18 +----------- 6 files changed, 46 insertions(+), 43 deletions(-) create mode 100644 app/styles/ui/_open-pull-request-header.scss diff --git a/app/src/ui/open-pull-request/open-pull-request-dialog.tsx b/app/src/ui/open-pull-request/open-pull-request-dialog.tsx index 447c5f5e942..e30f803d255 100644 --- a/app/src/ui/open-pull-request/open-pull-request-dialog.tsx +++ b/app/src/ui/open-pull-request/open-pull-request-dialog.tsx @@ -116,7 +116,14 @@ export class OpenPullRequestDialog extends React.Component @@ -160,6 +168,7 @@ export class OpenPullRequestDialog extends React.Component void @@ -64,6 +68,7 @@ export class OpenPullRequestDialogHeader extends React.Component 1 ? 's' : ''}` return ( @@ -99,6 +105,16 @@ export class OpenPullRequestDialogHeader extends React.Component{' '} from {currentBranch.name}.
+
+
Lines changed:
+ + , + +
) } diff --git a/app/src/ui/open-pull-request/pull-request-files-changed.tsx b/app/src/ui/open-pull-request/pull-request-files-changed.tsx index 90ee8edec5d..afdbfa4b481 100644 --- a/app/src/ui/open-pull-request/pull-request-files-changed.tsx +++ b/app/src/ui/open-pull-request/pull-request-files-changed.tsx @@ -25,7 +25,6 @@ import { clamp } from '../../lib/clamp' import { getDotComAPIEndpoint } from '../../lib/api' import { createCommitURL } from '../../lib/commit-url' import { DiffOptions } from '../diff/diff-options' -import { IChangesetData } from '../../lib/git' interface IPullRequestFilesChangedProps { readonly repository: Repository @@ -34,8 +33,8 @@ interface IPullRequestFilesChangedProps { /** The file whose diff should be displayed. */ readonly selectedFile: CommittedFileChange | null - /** The changeset data associated with the selected commit */ - readonly changesetData: IChangesetData + /** The files changed in the pull request. */ + readonly files: ReadonlyArray /** The diff that should be rendered */ readonly diff: IDiff | null @@ -233,7 +232,7 @@ export class PullRequestFilesChanged extends React.Component< } private onRowDoubleClick = (row: number) => { - const files = this.props.changesetData.files + const files = this.props.files const file = files[row] this.props.onOpenInExternalEditor(file.path) @@ -247,8 +246,6 @@ export class PullRequestFilesChanged extends React.Component<
Showing changes from all commits
- {this.renderLinesChanged()} -
-
+{changesetData.linesAdded}
-
-{changesetData.linesDeleted}
-
- ) - } - private renderFileList() { - const { changesetData, selectedFile, fileListWidth } = this.props + const { files, selectedFile, fileListWidth } = this.props return ( Date: Wed, 22 Oct 2025 18:59:54 -0500 Subject: [PATCH 061/865] Improve dialog dismissal and sign-in button state handling --- app/src/ui/dialog/dialog.tsx | 19 ++++++++++--------- app/src/ui/sign-in/sign-in.tsx | 7 +++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/src/ui/dialog/dialog.tsx b/app/src/ui/dialog/dialog.tsx index 48999d7258d..0eb89af0ada 100644 --- a/app/src/ui/dialog/dialog.tsx +++ b/app/src/ui/dialog/dialog.tsx @@ -657,16 +657,17 @@ export class Dialog extends React.Component { return } - // Ignore the first click right after the window's been focused. It could - // be the click that focused the window, in which case we don't wanna - // dismiss the dialog. - if (this.disableClickDismissal) { - this.disableClickDismissal = false - this.clearClickDismissalTimer() - return - } - if (!this.mouseEventIsInsideDialog(e)) { + // Ignore the first backdrop click right after the window's been focused. + // It could be the click that focused the window, in which case we don't + // want to dismiss the dialog. Only ignore backdrop clicks, not clicks on + // interactive elements like buttons. + if (this.disableClickDismissal) { + this.disableClickDismissal = false + this.clearClickDismissalTimer() + return + } + // The user has pressed down on their pointer device outside of the // dialog (i.e. on the backdrop). Now we subscribe to the global // mouse up event where we can make sure that they release the pointer diff --git a/app/src/ui/sign-in/sign-in.tsx b/app/src/ui/sign-in/sign-in.tsx index d9ab5d2d542..bd5de941cd2 100644 --- a/app/src/ui/sign-in/sign-in.tsx +++ b/app/src/ui/sign-in/sign-in.tsx @@ -143,7 +143,8 @@ export class SignIn extends React.Component { @@ -226,8 +227,6 @@ export class SignIn extends React.Component { return null } - const disabled = state.loading - const errors = state.error ? ( {state.error.message} ) : null @@ -241,7 +240,7 @@ export class SignIn extends React.Component { Date: Fri, 24 Oct 2025 09:22:04 -0500 Subject: [PATCH 062/865] Fix: Show whitespace hint on context menu in diff row When "Hide whitespace changes" is enabled, right-clicking on diff lines or hunk handles now properly shows the whitespace hint popover with the option to disable whitespace hiding, matching the existing left-click behavior. - Updated onContextMenuLineNumber to show whitespace hint when hideWhitespaceInDiff is true - Updated onContextMenuHunk to show whitespace hint when hideWhitespaceInDiff is true - Ensures consistent UX between left-click and right-click interactions --- app/src/ui/diff/side-by-side-diff-row.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/ui/diff/side-by-side-diff-row.tsx b/app/src/ui/diff/side-by-side-diff-row.tsx index 0677247b648..e8a9e3219ad 100644 --- a/app/src/ui/diff/side-by-side-diff-row.tsx +++ b/app/src/ui/diff/side-by-side-diff-row.tsx @@ -1019,6 +1019,10 @@ export class SideBySideDiffRow extends React.Component< private onContextMenuLineNumber = (evt: React.MouseEvent) => { if (this.props.hideWhitespaceInDiff) { + const column = this.getDiffColumn(evt.currentTarget) + if (column !== null) { + this.setState({ showWhitespaceHint: column }) + } return } @@ -1030,6 +1034,13 @@ export class SideBySideDiffRow extends React.Component< private onContextMenuHunk = () => { if (this.props.hideWhitespaceInDiff) { + const { row } = this.props + // Prefer left hand side popovers when clicking hunk except for when + // the left hand side doesn't have a gutter + const column = + row.type === DiffRowType.Added ? DiffColumn.After : DiffColumn.Before + + this.setState({ showWhitespaceHint: column }) return } From 443ca9288322634667eb4bf2c2c3565df2abde95 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Mon, 27 Oct 2025 15:20:06 +0100 Subject: [PATCH 063/865] Release 3.5.4-beta1 with new features and fixes Bump version to 3.5.4-beta1 and update changelog with new features, improvements, and bug fixes, including PR preview enhancements, commit message options, Copilot support for non-GitHub repos, and UI improvements. --- app/package.json | 2 +- changelog.json | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/package.json b/app/package.json index fd64a2c1ea9..f492873dab0 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.5.3", + "version": "3.5.4-beta1", "main": "./main.js", "repository": { "type": "git", diff --git a/changelog.json b/changelog.json index 5cc06885a96..0884d7d6f2f 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,15 @@ { "releases": { + "3.5.4-beta1": [ + "[Added] Display line change count in PR Preview Dialog - #21126. Thanks @iammola!", + "[Added] Allow users to skip commit message override confirmation - #21025. Thanks @ilyassesalama!", + "[Added] Allow generating commits with Copilot in non-GitHub repositories - #20698. Thanks @schroedermarius!", + "[Fixed] Improve host discovery when using authenticating proxies - #19039 #19120", + "[Fixed] Fix diff search results highlights not visible on addition hunks - #21134", + "[Fixed] Add Copilot commit message generation to context menu - #21000. Thanks @zekariasasaminew!", + "[Fixed] Override system accent color for checkboxes and radio buttons - #21088", + "[Improved] Increased title bar height on macOS Tahoe - #21135. Thanks @berkcebi!" + ], "3.5.3": [ "[Added] Add Ptyxis shell integration - #20963. Thanks @logonoff!", "[Fixed] Copilot message generation in progress message is announced to screen readers - #21008", From 6c2f43f323a564ee41b636f540066ac847acaa2f Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Tue, 28 Oct 2025 15:54:54 +0100 Subject: [PATCH 064/865] Sync accountEmail state with preferredAccountEmail prop Updates the accountEmail state when the preferredAccountEmail prop changes and matches the previous state, ensuring the component reflects the latest preferred account email. --- app/src/ui/changes/commit-message-avatar.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/ui/changes/commit-message-avatar.tsx b/app/src/ui/changes/commit-message-avatar.tsx index 65824c195ee..9a785962096 100644 --- a/app/src/ui/changes/commit-message-avatar.tsx +++ b/app/src/ui/changes/commit-message-avatar.tsx @@ -123,6 +123,13 @@ export class CommitMessageAvatar extends React.Component< ) { this.determineGitConfigLocation() } + + if ( + this.props.preferredAccountEmail !== prevProps.preferredAccountEmail && + this.state.accountEmail === prevProps.preferredAccountEmail + ) { + this.setState({ accountEmail: this.props.preferredAccountEmail }) + } } private async determineGitConfigLocation() { From 65efca80383868631092e9836e171933602a9648 Mon Sep 17 00:00:00 2001 From: zeki <153834382+zekariasasaminew@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:06:54 -0500 Subject: [PATCH 065/865] Fix: menu bar flickering when profile modal is open by adding capture phase click listener --- app/src/ui/changes/commit-message-avatar.tsx | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/src/ui/changes/commit-message-avatar.tsx b/app/src/ui/changes/commit-message-avatar.tsx index 9a785962096..60f00f65da4 100644 --- a/app/src/ui/changes/commit-message-avatar.tsx +++ b/app/src/ui/changes/commit-message-avatar.tsx @@ -104,6 +104,7 @@ export class CommitMessageAvatar extends React.Component< > { private avatarButtonRef: HTMLButtonElement | null = null private warningBadgeRef = React.createRef() + private documentClickListener: (event: MouseEvent) => void public constructor(props: ICommitMessageAvatarProps) { super(props) @@ -114,6 +115,31 @@ export class CommitMessageAvatar extends React.Component< isGitConfigLocal: false, } this.determineGitConfigLocation() + + // Create the document click listener + this.documentClickListener = (event: MouseEvent) => { + if (this.state.isPopoverOpen && event.target instanceof Element) { + // Check if click is on app menu bar or its descendants + const appMenuBar = document.getElementById('app-menu-bar') + if ( + appMenuBar && + (appMenuBar.contains(event.target) || appMenuBar === event.target) + ) { + // Close popover when clicking on app menu + this.closePopover() + } + } + } + } + + public componentDidMount() { + // Add global click listener to detect app menu clicks + document.addEventListener('click', this.documentClickListener, true) // Use capture phase + } + + public componentWillUnmount() { + // Remove global click listener + document.removeEventListener('click', this.documentClickListener, true) } public componentDidUpdate(prevProps: ICommitMessageAvatarProps) { From 47ba2217f5cc0d0dd9865d37ea4dc7a241b1c938 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Thu, 30 Oct 2025 11:48:47 +0100 Subject: [PATCH 066/865] Add test for initializing uninitialized submodules on checkout Introduces a helper to set up a repository with an uninitialized submodule and adds a unit test to verify that checking out a branch with such a submodule correctly initializes it. This ensures proper handling of submodules that have not been initialized when switching branches. --- app/test/helpers/repositories.ts | 48 ++++++++++++++++++++++++++++++ app/test/unit/git/checkout-test.ts | 45 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/app/test/helpers/repositories.ts b/app/test/helpers/repositories.ts index 699d979d73b..daf118a27f9 100644 --- a/app/test/helpers/repositories.ts +++ b/app/test/helpers/repositories.ts @@ -298,3 +298,51 @@ export async function setupLocalForkOfRepository( await git(['clone', '--local', `${upstream.path}`, path], path, 'clone') return new Repository(path, -1, null, false) } + +/** + * Setup a repository with an uninitialized submodule in a branch + * + * @returns the new local repository + * + * The repository will have: + * - Two commits on the main branch + * - A branch named 'branch-with-submodule' with a submodule added + * - The submodule is uninitialized (its .git/modules entry is removed) + * + * This simulates a scenario where a submodule exists in a branch but + * hasn't been initialized yet when checking out that branch. + */ +export async function setupRepositoryWithUninitializedSubmodule( + t: TestContext +): Promise { + const repo = await setupTwoCommitRepo(t) + + // Create a submodule repository + const submoduleRepo = await setupTwoCommitRepo(t) + + // Create a new branch and add the submodule + await exec(['checkout', '-b', 'branch-with-submodule'], repo.path) + + await exec( + [ + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + submoduleRepo.path, + 'test-submodule', + ], + repo.path + ) + await exec(['commit', '-m', 'Add submodule'], repo.path) + + // Go back to main branch + await exec(['checkout', 'master'], repo.path) + + // Remove the .git/modules directory for the submodule to make it uninitialized + const modulesPath = Path.join(repo.path, '.git', 'modules', 'test-submodule') + await FSE.remove(modulesPath) + await FSE.remove(Path.join(repo.path, 'test-submodule')) + + return repo +} diff --git a/app/test/unit/git/checkout-test.ts b/app/test/unit/git/checkout-test.ts index d507958de2a..b25a3a79224 100644 --- a/app/test/unit/git/checkout-test.ts +++ b/app/test/unit/git/checkout-test.ts @@ -1,9 +1,12 @@ import { describe, it } from 'node:test' import assert from 'node:assert' +import * as Path from 'path' +import * as FSE from 'fs-extra' import { shell } from '../../helpers/test-app-shell' import { setupEmptyRepository, setupFixtureRepository, + setupRepositoryWithUninitializedSubmodule, } from '../../helpers/repositories' import { Repository } from '../../../src/models/repository' @@ -167,5 +170,47 @@ describe('git/checkout', () => { const status = await getStatusOrThrow(repository) assert.equal(status.workingDirectory.files.length, 0) }) + + it('initializes an uninitialized submodule when checking out a branch', async t => { + const repository = await setupRepositoryWithUninitializedSubmodule(t) + + const branches = await getBranches(repository) + const branchWithSubmodule = branches.find(b => b.name !== 'master') + + if (branchWithSubmodule == null) { + throw new Error(`Could not find branch other than 'master'`) + } + + await checkoutBranch(repository, branchWithSubmodule, null) + + // Verify we're on the correct branch + const statusOutput = await exec(['status'], repository.path) + assert.ok( + statusOutput.stdout.includes(`On branch ${branchWithSubmodule.name}`) + ) + + // Verify the submodule is initialized and has the correct commits + const submodulePath = Path.join(repository.path, 'test-submodule') + const submoduleGitPath = Path.join(submodulePath, '.git') + + // Check that submodule .git exists (either as file or directory) + const submoduleGitExists = await FSE.pathExists(submoduleGitPath) + assert.equal( + submoduleGitExists, + true, + 'Submodule .git should exist after checkout' + ) + + // Verify submodule has two commits + const submoduleLog = await exec(['log', '--oneline'], submodulePath) + assert.equal(submoduleLog.stdout.split('\n').length, 2) + + // Verify submodule is in branch 'master' + const submoduleStatus = await exec(['status'], submodulePath) + assert.ok( + submoduleStatus.stdout.includes('On branch master'), + 'Submodule should be on branch master after checkout' + ) + }) }) }) From b0e3a9667f475c7eb75fb4c4153bb34340a1037c Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Thu, 30 Oct 2025 14:17:06 +0100 Subject: [PATCH 067/865] (WIP) Use `git submodule update --init --recursive` instead of `--recurse-submodule` --- app/src/lib/git/checkout.ts | 121 +++++++++++++++++++++++++++-- app/test/unit/git/checkout-test.ts | 17 ++-- 2 files changed, 124 insertions(+), 14 deletions(-) diff --git a/app/src/lib/git/checkout.ts b/app/src/lib/git/checkout.ts index 7a026ccd80e..b7ce70231c9 100644 --- a/app/src/lib/git/checkout.ts +++ b/app/src/lib/git/checkout.ts @@ -5,6 +5,8 @@ import { ICheckoutProgress } from '../../models/progress' import { CheckoutProgressParser, executionOptionsWithProgress, + GitProgressParser, + IProgressStep, } from '../progress' import { AuthenticationErrors } from './authentication' import { enableRecurseSubmodulesFlag } from '../feature-flag' @@ -17,6 +19,19 @@ import { ManualConflictResolution } from '../../models/manual-conflict-resolutio import { CommitOneLine, shortenSHA } from '../../models/commit' import { IRemote } from '../../models/remote' +/** + * A progress parser that handles both checkout and submodule update steps. + */ +class CheckoutWithSubmodulesProgressParser extends GitProgressParser { + public constructor() { + const steps: ReadonlyArray = [ + { title: 'Checking out files', weight: 0.5 }, + { title: 'Updating files', weight: 0.5 }, + ] + super(steps) + } +} + export type ProgressCallback = (progress: ICheckoutProgress) => void function getCheckoutArgs(progressCallback?: ProgressCallback) { @@ -29,7 +44,6 @@ async function getBranchCheckoutArgs(branch: Branch) { ...(branch.type === BranchType.Remote ? ['-b', branch.nameWithoutRemote] : []), - ...(enableRecurseSubmodulesFlag() ? ['--recurse-submodules'] : []), '--', ] } @@ -84,6 +98,79 @@ async function getCheckoutOpts( ) } +/** + * Update submodules after a checkout operation. + * + * @param repository - The repository in which to update submodules + * @param title - The title to use for progress reporting + * @param target - The target branch/commit being checked out + * @param currentRemote - The current remote for environment setup + * @param progressCallback - An optional function which will be invoked + * with information about the current progress + * of the submodule update operation. + */ +async function updateSubmodulesAfterCheckout( + repository: Repository, + title: string, + target: string, + currentRemote: IRemote | null, + progressCallback: ProgressCallback | undefined, + allowFileProtocol: boolean +): Promise { + if (!enableRecurseSubmodulesFlag()) { + return + } + + const opts: IGitStringExecutionOptions = { + env: await envForRemoteOperation( + getFallbackUrlForProxyResolve(repository, currentRemote) + ), + expectedErrors: AuthenticationErrors, + } + + const args = [ + ...(allowFileProtocol ? ['-c', 'protocol.file.allow=always'] : []), + 'submodule', + 'update', + '--init', + '--recursive', + ] + + if (!progressCallback) { + await git(args, repository.path, 'updateSubmodules', opts) + return + } + + const kind = 'checkout' + + const progressOpts = await executionOptionsWithProgress( + { ...opts, trackLFSProgress: true }, + new CheckoutWithSubmodulesProgressParser(), + progress => { + if (progress.kind === 'progress') { + const description = progress.details.text + // Scale progress from 50% to 100% (second half of checkout operation) + const value = 0.5 + progress.percent * 0.5 + + progressCallback({ + kind, + title, + description, + value, + target, + }) + } + } + ) + + await git( + [...args, '--progress'], + repository.path, + 'updateSubmodules', + progressOpts + ) +} + /** * Check out the given branch. * @@ -102,11 +189,13 @@ export async function checkoutBranch( repository: Repository, branch: Branch, currentRemote: IRemote | null, - progressCallback?: ProgressCallback + progressCallback?: ProgressCallback, + allowFileProtocol: boolean = false ): Promise { + const title = `Checking out branch ${branch.name}` const opts = await getCheckoutOpts( repository, - `Checking out branch ${branch.name}`, + title, branch.name, currentRemote, progressCallback, @@ -118,6 +207,16 @@ export async function checkoutBranch( await git(args, repository.path, 'checkoutBranch', opts) + // Update submodules after checkout + await updateSubmodulesAfterCheckout( + repository, + title, + branch.name, + currentRemote, + progressCallback, + allowFileProtocol + ) + // we return `true` here so `GitStore.performFailableGitOperation` // will return _something_ differentiable from `undefined` if this succeeds return true @@ -142,13 +241,15 @@ export async function checkoutCommit( repository: Repository, commit: CommitOneLine, currentRemote: IRemote | null, - progressCallback?: ProgressCallback + progressCallback?: ProgressCallback, + allowFileProtocol: boolean = false ): Promise { const title = `Checking out ${__DARWIN__ ? 'Commit' : 'commit'}` + const target = shortenSHA(commit.sha) const opts = await getCheckoutOpts( repository, title, - shortenSHA(commit.sha), + target, currentRemote, progressCallback ) @@ -158,6 +259,16 @@ export async function checkoutCommit( await git(args, repository.path, 'checkoutCommit', opts) + // Update submodules after checkout + await updateSubmodulesAfterCheckout( + repository, + title, + target, + currentRemote, + progressCallback, + allowFileProtocol + ) + // we return `true` here so `GitStore.performFailableGitOperation` // will return _something_ differentiable from `undefined` if this succeeds return true diff --git a/app/test/unit/git/checkout-test.ts b/app/test/unit/git/checkout-test.ts index b25a3a79224..f34c3ff551d 100644 --- a/app/test/unit/git/checkout-test.ts +++ b/app/test/unit/git/checkout-test.ts @@ -181,7 +181,13 @@ describe('git/checkout', () => { throw new Error(`Could not find branch other than 'master'`) } - await checkoutBranch(repository, branchWithSubmodule, null) + await checkoutBranch( + repository, + branchWithSubmodule, + null, + undefined, + true + ) // Verify we're on the correct branch const statusOutput = await exec(['status'], repository.path) @@ -203,14 +209,7 @@ describe('git/checkout', () => { // Verify submodule has two commits const submoduleLog = await exec(['log', '--oneline'], submodulePath) - assert.equal(submoduleLog.stdout.split('\n').length, 2) - - // Verify submodule is in branch 'master' - const submoduleStatus = await exec(['status'], submodulePath) - assert.ok( - submoduleStatus.stdout.includes('On branch master'), - 'Submodule should be on branch master after checkout' - ) + assert.equal(submoduleLog.stdout.trim().split('\n').length, 2) }) }) }) From 399baac6c1edf67f1e4098d50cb06bd2275ef37b Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Thu, 30 Oct 2025 16:00:36 +0100 Subject: [PATCH 068/865] Refactor checkout progress and submodule update handling Replaces CheckoutWithSubmodulesProgressParser with a more flexible IGitProgressParser interface and updates submodule progress reporting logic. Adjusts progress value scaling for submodule updates in branch and commit checkout functions to improve accuracy and user feedback. Cleans up parser usage in progress-related modules for consistency. Co-Authored-By: Markus Olsson <634063+niik@users.noreply.github.com> --- app/src/lib/git/checkout.ts | 112 ++++++++++++++++++--------- app/src/lib/progress/from-process.ts | 6 +- app/src/lib/progress/git.ts | 20 ++++- 3 files changed, 97 insertions(+), 41 deletions(-) diff --git a/app/src/lib/git/checkout.ts b/app/src/lib/git/checkout.ts index b7ce70231c9..2a861a61ec2 100644 --- a/app/src/lib/git/checkout.ts +++ b/app/src/lib/git/checkout.ts @@ -5,8 +5,7 @@ import { ICheckoutProgress } from '../../models/progress' import { CheckoutProgressParser, executionOptionsWithProgress, - GitProgressParser, - IProgressStep, + IGitOutput, } from '../progress' import { AuthenticationErrors } from './authentication' import { enableRecurseSubmodulesFlag } from '../feature-flag' @@ -19,21 +18,10 @@ import { ManualConflictResolution } from '../../models/manual-conflict-resolutio import { CommitOneLine, shortenSHA } from '../../models/commit' import { IRemote } from '../../models/remote' -/** - * A progress parser that handles both checkout and submodule update steps. - */ -class CheckoutWithSubmodulesProgressParser extends GitProgressParser { - public constructor() { - const steps: ReadonlyArray = [ - { title: 'Checking out files', weight: 0.5 }, - { title: 'Updating files', weight: 0.5 }, - ] - super(steps) - } -} - export type ProgressCallback = (progress: ICheckoutProgress) => void +const SubmoduleUpdateStepWeight = 0.1 + function getCheckoutArgs(progressCallback?: ProgressCallback) { return ['checkout', ...(progressCallback ? ['--progress'] : [])] } @@ -141,34 +129,54 @@ async function updateSubmodulesAfterCheckout( return } + // Initial progress + progressCallback({ + kind: 'checkout', + title, + description: 'Updating submodules', + value: 0, + target, + }) + const kind = 'checkout' const progressOpts = await executionOptionsWithProgress( { ...opts, trackLFSProgress: true }, - new CheckoutWithSubmodulesProgressParser(), + { + parse(line: string): IGitOutput { + return { + kind: 'context', + text: `Updating submodules: ${line}`, + percent: 0.5, + } + }, + }, progress => { - if (progress.kind === 'progress') { - const description = progress.details.text - // Scale progress from 50% to 100% (second half of checkout operation) - const value = 0.5 + progress.percent * 0.5 + const description = + progress.kind === 'progress' ? progress.details.text : progress.text - progressCallback({ - kind, - title, - description, - value, - target, - }) - } + const value = progress.percent + + progressCallback({ + kind, + title, + description, + value, + target, + }) } ) - await git( - [...args, '--progress'], - repository.path, - 'updateSubmodules', - progressOpts - ) + await git(args, repository.path, 'updateSubmodules', progressOpts) + + // Final progress + progressCallback({ + kind, + title, + description: 'Submodules updated', + value: 1, + target, + }) } /** @@ -198,7 +206,13 @@ export async function checkoutBranch( title, branch.name, currentRemote, - progressCallback, + progressCallback + ? progress => + progressCallback({ + ...progress, + value: progress.value * (1 - SubmoduleUpdateStepWeight), + }) + : undefined, `Switching to ${__DARWIN__ ? 'Branch' : 'branch'}` ) @@ -213,7 +227,16 @@ export async function checkoutBranch( title, branch.name, currentRemote, - progressCallback, + progressCallback + ? progress => + progressCallback({ + ...progress, + value: + 1 - + SubmoduleUpdateStepWeight + + progress.value * SubmoduleUpdateStepWeight, + }) + : undefined, allowFileProtocol ) @@ -252,6 +275,12 @@ export async function checkoutCommit( target, currentRemote, progressCallback + ? progress => + progressCallback({ + ...progress, + value: progress.value * (1 - SubmoduleUpdateStepWeight), + }) + : undefined ) const baseArgs = getCheckoutArgs(progressCallback) @@ -265,7 +294,16 @@ export async function checkoutCommit( title, target, currentRemote, - progressCallback, + progressCallback + ? progress => + progressCallback({ + ...progress, + value: + 1 - + SubmoduleUpdateStepWeight + + progress.value * SubmoduleUpdateStepWeight, + }) + : undefined, allowFileProtocol ) diff --git a/app/src/lib/progress/from-process.ts b/app/src/lib/progress/from-process.ts index 4c19dc8284e..675379c611d 100644 --- a/app/src/lib/progress/from-process.ts +++ b/app/src/lib/progress/from-process.ts @@ -3,7 +3,7 @@ import * as Fs from 'fs' import * as Path from 'path' import byline from 'byline' -import { GitProgressParser, IGitProgress, IGitOutput } from './git' +import { IGitProgress, IGitOutput, IGitProgressParser } from './git' import { IGitExecutionOptions } from '../git/core' import { merge } from '../merge' import { GitLFSProgressParser, createLFSProgressFile } from './lfs' @@ -20,7 +20,7 @@ export async function executionOptionsWithProgress< T extends IGitExecutionOptions >( options: T, - parser: GitProgressParser, + parser: IGitProgressParser, progressCallback: (progress: IGitProgress | IGitOutput) => void ): Promise { let lfsProgressPath = null @@ -51,7 +51,7 @@ export async function executionOptionsWithProgress< * process and parsing its contents using the provided parser. */ function createProgressProcessCallback( - parser: GitProgressParser, + parser: IGitProgressParser, lfsProgressPath: string | null, progressCallback: (progress: IGitProgress | IGitOutput) => void ): (process: ChildProcess) => void { diff --git a/app/src/lib/progress/git.ts b/app/src/lib/progress/git.ts index 3fe10246491..a27a0e51466 100644 --- a/app/src/lib/progress/git.ts +++ b/app/src/lib/progress/git.ts @@ -137,6 +137,24 @@ export interface IGitProgressInfo { readonly text: string } +/** + * Interface for classes interpreting progress output from `git` + * and turning that into a percentage value estimating the overall progress + * of the an operation. An operation could be something like `git fetch` + * which contains multiple steps, each individually reported by Git as + * progress events between 0 and 100%. + */ +export interface IGitProgressParser { + /** + * Parse the given line of output from Git, returns either an `IGitProgress` + * instance if the line could successfully be parsed as a Git progress + * event whose title was registered with this parser or an `IGitOutput` + * instance if the line couldn't be parsed or if the title wasn't + * registered with the parser. + */ + parse(line: string): IGitProgress | IGitOutput +} + /** * A utility class for interpreting progress output from `git` * and turning that into a percentage value estimating the overall progress @@ -147,7 +165,7 @@ export interface IGitProgressInfo { * A parser cannot be reused, it's mean to parse a single stderr stream * for Git. */ -export class GitProgressParser { +export class GitProgressParser implements IGitProgressParser { private readonly steps: ReadonlyArray /* The provided steps should always occur in order but some From 2438c6e99e547140dfc9f03d561ae23d7c040645 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Thu, 30 Oct 2025 16:11:10 +0100 Subject: [PATCH 069/865] Refactor progress calculation in checkout operations Introduced clampProgress helper to standardize progress value scaling during branch and commit checkout. Replaced manual progress calculations with clampProgress for improved readability and maintainability. Co-Authored-By: Markus Olsson <634063+niik@users.noreply.github.com> --- app/src/lib/git/checkout.ts | 44 ++++++++++++++----------------------- 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/app/src/lib/git/checkout.ts b/app/src/lib/git/checkout.ts index 2a861a61ec2..910f2aa23ae 100644 --- a/app/src/lib/git/checkout.ts +++ b/app/src/lib/git/checkout.ts @@ -20,7 +20,19 @@ import { IRemote } from '../../models/remote' export type ProgressCallback = (progress: ICheckoutProgress) => void -const SubmoduleUpdateStepWeight = 0.1 +const CheckoutStepWeight = 0.9 + +function clampProgress( + minimum: number, + maximum: number, + progressCallback: ProgressCallback +): ProgressCallback { + return (progress: ICheckoutProgress) => + progressCallback({ + ...progress, + value: minimum + progress.value * (maximum - minimum), + }) +} function getCheckoutArgs(progressCallback?: ProgressCallback) { return ['checkout', ...(progressCallback ? ['--progress'] : [])] @@ -207,11 +219,7 @@ export async function checkoutBranch( branch.name, currentRemote, progressCallback - ? progress => - progressCallback({ - ...progress, - value: progress.value * (1 - SubmoduleUpdateStepWeight), - }) + ? clampProgress(0, CheckoutStepWeight, progressCallback) : undefined, `Switching to ${__DARWIN__ ? 'Branch' : 'branch'}` ) @@ -228,14 +236,7 @@ export async function checkoutBranch( branch.name, currentRemote, progressCallback - ? progress => - progressCallback({ - ...progress, - value: - 1 - - SubmoduleUpdateStepWeight + - progress.value * SubmoduleUpdateStepWeight, - }) + ? clampProgress(CheckoutStepWeight, 1, progressCallback) : undefined, allowFileProtocol ) @@ -275,11 +276,7 @@ export async function checkoutCommit( target, currentRemote, progressCallback - ? progress => - progressCallback({ - ...progress, - value: progress.value * (1 - SubmoduleUpdateStepWeight), - }) + ? clampProgress(0, CheckoutStepWeight, progressCallback) : undefined ) @@ -295,14 +292,7 @@ export async function checkoutCommit( target, currentRemote, progressCallback - ? progress => - progressCallback({ - ...progress, - value: - 1 - - SubmoduleUpdateStepWeight + - progress.value * SubmoduleUpdateStepWeight, - }) + ? clampProgress(CheckoutStepWeight, 1, progressCallback) : undefined, allowFileProtocol ) From f5d2436a6effff86c60db456b85a6b63ae5248ed Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Thu, 30 Oct 2025 16:33:52 +0100 Subject: [PATCH 070/865] Improve submodule progress calculation in checkout Progress reporting for submodule updates during checkout now uses an exponential function based on detected submodule events, providing a more realistic and gradual progress indication when the number of submodules is unknown. Co-Authored-By: Markus Olsson <634063+niik@users.noreply.github.com> --- app/src/lib/git/checkout.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/lib/git/checkout.ts b/app/src/lib/git/checkout.ts index 910f2aa23ae..b4a8888e39e 100644 --- a/app/src/lib/git/checkout.ts +++ b/app/src/lib/git/checkout.ts @@ -152,14 +152,28 @@ async function updateSubmodulesAfterCheckout( const kind = 'checkout' + let submoduleEventCount = 0 + const progressOpts = await executionOptionsWithProgress( { ...opts, trackLFSProgress: true }, { parse(line: string): IGitOutput { + if ( + line.match(/^Submodule path (.)+?: checked out /) || + line.startsWith('Cloning into ') + ) { + submoduleEventCount += 1 + } + return { kind: 'context', text: `Updating submodules: ${line}`, - percent: 0.5, + // Math taken from https://math.stackexchange.com/a/2323106 + // We do this to fake a progress that slows down as we process more + // events, as we don't know how many submodules there are upfront, or + // what does git have to do with them (cloning, just checking them + // out...) + percent: 1 - Math.exp(-submoduleEventCount * 0.25), } }, }, From c9580742602606020ef37c3cf6929af08066b954 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:23:22 -0400 Subject: [PATCH 071/865] Update pull request badge styling Changed the background to none and added a border for .pr-badge in both open and closed dropdown states. Increased badge height from 18px to 22px for improved visibility. --- app/styles/ui/_pull-request-badge.scss | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/styles/ui/_pull-request-badge.scss b/app/styles/ui/_pull-request-badge.scss index ac755898081..328c0ab26a8 100644 --- a/app/styles/ui/_pull-request-badge.scss +++ b/app/styles/ui/_pull-request-badge.scss @@ -1,13 +1,14 @@ -.toolbar-dropdown.open .pr-badge { - background: var(--toolbar-badge-active-background-color); -} +.toolbar-dropdown .pr-badge { + border: 1px solid var(--toolbar-badge-background-color); + background: var(--toolbar-background-color); -.toolbar-dropdown:not(.open) .pr-badge { - background: var(--toolbar-badge-background-color); + &:hover { + background-color: var(--toolbar-button-hover-background-color); + } } .pr-badge { - --height: 18px; + --height: 22px; display: flex; flex-direction: row; From bd5e1eb2d577afb0c8d4c4f16c66dbccec0e5456 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:43:48 -0400 Subject: [PATCH 072/865] Update _pull-request-badge.scss --- app/styles/ui/_pull-request-badge.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/styles/ui/_pull-request-badge.scss b/app/styles/ui/_pull-request-badge.scss index 328c0ab26a8..c4fe225ec55 100644 --- a/app/styles/ui/_pull-request-badge.scss +++ b/app/styles/ui/_pull-request-badge.scss @@ -7,6 +7,10 @@ } } +.toolbar-dropdown.open .pr-badge { + background: none; +} + .pr-badge { --height: 22px; From b16b6ca737e248a006a692a9e4a0d2120b773659 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:55:57 -0400 Subject: [PATCH 073/865] Add status tooltip to CI check run list items Introduces a 'hasStatusTooltip' prop to CI check run list components, enabling tooltips that display the check run conclusion adjective. Updates related components to support and utilize this new prop for improved status visibility. --- app/src/ui/check-runs/ci-check-run-list-item.tsx | 15 +++++++++++++-- app/src/ui/check-runs/ci-check-run-list.tsx | 4 ++++ .../ui/check-runs/ci-check-run-rerun-dialog.tsx | 1 + 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/src/ui/check-runs/ci-check-run-list-item.tsx b/app/src/ui/check-runs/ci-check-run-list-item.tsx index 791df662f91..aec26d224cf 100644 --- a/app/src/ui/check-runs/ci-check-run-list-item.tsx +++ b/app/src/ui/check-runs/ci-check-run-list-item.tsx @@ -1,5 +1,8 @@ import * as React from 'react' -import { IRefCheck } from '../../lib/ci-checks/ci-checks' +import { + getCheckRunConclusionAdjective, + IRefCheck, +} from '../../lib/ci-checks/ci-checks' import { Octicon } from '../octicons' import { getClassNameForCheck, getSymbolForCheck } from '../branches/ci-status' import classNames from 'classnames' @@ -39,6 +42,9 @@ interface ICICheckRunListItemProps { **/ readonly isHeader?: false + /** Whether the check run status has a tooltip */ + readonly hasStatusTooltip?: boolean + /** Callback for when a check run is clicked */ readonly onCheckRunExpansionToggleClick: (checkRun: IRefCheck) => void @@ -78,7 +84,7 @@ export class CICheckRunListItem extends React.PureComponent { - const { checkRun } = this.props + const { checkRun, hasStatusTooltip } = this.props return (
@@ -88,6 +94,11 @@ export class CICheckRunListItem extends React.PureComponent
) diff --git a/app/src/ui/check-runs/ci-check-run-list.tsx b/app/src/ui/check-runs/ci-check-run-list.tsx index 2e9d7657ecb..33c2450b353 100644 --- a/app/src/ui/check-runs/ci-check-run-list.tsx +++ b/app/src/ui/check-runs/ci-check-run-list.tsx @@ -23,6 +23,9 @@ interface ICICheckRunListProps { /** Showing a condensed view */ readonly isCondensedView?: boolean + /** Whether the check run status has a tooltip */ + readonly hasStatusTooltip?: boolean + /** Callback to opens check runs target url (maybe GitHub, maybe third party) */ readonly onViewCheckDetails?: (checkRun: IRefCheck) => void @@ -167,6 +170,7 @@ export class CICheckRunList extends React.PureComponent< onRerunJob={this.props.onRerunJob} isCondensedView={this.props.isCondensedView} isHeader={false} + hasStatusTooltip={this.props.hasStatusTooltip} /> ) }) diff --git a/app/src/ui/check-runs/ci-check-run-rerun-dialog.tsx b/app/src/ui/check-runs/ci-check-run-rerun-dialog.tsx index 31b5459110a..7aa9b4fc333 100644 --- a/app/src/ui/check-runs/ci-check-run-rerun-dialog.tsx +++ b/app/src/ui/check-runs/ci-check-run-rerun-dialog.tsx @@ -142,6 +142,7 @@ export class CICheckRunRerunDialog extends React.Component< checkRuns={this.state.rerunnable} notExpandable={true} isCondensedView={true} + hasStatusTooltip={true} />
) From 625ed5eaef99babaa5dc912408f8c974ac77f1ea Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:22:32 -0400 Subject: [PATCH 074/865] Replace external link button with LinkButton in test notifications Refactored TestNotificationItemRowContent to use LinkButton instead of a custom Button for external links, improving accessibility and consistency. Added linkButtonDescription prop for better context, and updated styles to inherit color for the link button. --- .../test-notifications/test-notifications.tsx | 26 +++++++++---------- .../ui/dialogs/_test-notifications.scss | 4 +++ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/app/src/ui/test-notifications/test-notifications.tsx b/app/src/ui/test-notifications/test-notifications.tsx index c4eaa82797c..a38f18ed231 100644 --- a/app/src/ui/test-notifications/test-notifications.tsx +++ b/app/src/ui/test-notifications/test-notifications.tsx @@ -121,9 +121,11 @@ class TestNotificationItemRowContent extends React.Component<{ readonly leftAccessory?: JSX.Element readonly html_url?: string readonly dispatcher: Dispatcher + readonly linkButtonDescription: string }> { public render() { - const { leftAccessory, html_url, children } = this.props + const { leftAccessory, html_url, children, linkButtonDescription } = + this.props return (
@@ -131,25 +133,18 @@ class TestNotificationItemRowContent extends React.Component<{
{children}
{html_url && (
- +
)}
) } - - private onExternalLinkClick = (e: React.MouseEvent) => { - const { dispatcher, html_url } = this.props - - if (html_url === undefined) { - return - } - - e.stopPropagation() - dispatcher.openInBrowser(html_url) - } } export class TestNotifications extends React.Component< @@ -657,6 +652,7 @@ export class TestNotifications extends React.Component< {comment.body} @@ -673,6 +669,7 @@ export class TestNotifications extends React.Component< {review.body || Review without body} @@ -703,6 +700,7 @@ export class TestNotifications extends React.Component< diff --git a/app/styles/ui/dialogs/_test-notifications.scss b/app/styles/ui/dialogs/_test-notifications.scss index 00cb1cfa012..e97aeaefaa4 100644 --- a/app/styles/ui/dialogs/_test-notifications.scss +++ b/app/styles/ui/dialogs/_test-notifications.scss @@ -72,4 +72,8 @@ .pr-draft-icon { color: var(--pr-draft-icon-color); } + + .link-button-component { + color: inherit; + } } From c91ddd2c8600f394334beba500766c32cc4c4f63 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:26:25 -0400 Subject: [PATCH 075/865] Update _test-notifications.scss --- app/styles/ui/dialogs/_test-notifications.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/styles/ui/dialogs/_test-notifications.scss b/app/styles/ui/dialogs/_test-notifications.scss index e97aeaefaa4..d4924fbc101 100644 --- a/app/styles/ui/dialogs/_test-notifications.scss +++ b/app/styles/ui/dialogs/_test-notifications.scss @@ -74,6 +74,6 @@ } .link-button-component { - color: inherit; + color: inherit; } } From 299eedd316c6f70f9f590cb4fdff89b0c357fb43 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:34:59 -0400 Subject: [PATCH 076/865] Restore focus to parent menu pane on submenu close Adds refs to menu pane elements to restore focus to the parent pane when navigating back from a submenu with the left arrow key. This prevents the menu bar from detecting focus loss and closing the entire menu unexpectedly. --- app/src/ui/app-menu/app-menu.tsx | 18 ++++++++++++++++++ app/src/ui/app-menu/menu-pane.tsx | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/app/src/ui/app-menu/app-menu.tsx b/app/src/ui/app-menu/app-menu.tsx index 5c91db04296..b51841463b4 100644 --- a/app/src/ui/app-menu/app-menu.tsx +++ b/app/src/ui/app-menu/app-menu.tsx @@ -77,6 +77,12 @@ export class AppMenu extends React.Component { */ private expandCollapseTimer: number | null = null + /** + * Refs to the menu pane elements, indexed by depth. Used to restore + * focus when navigating back from a submenu with the left arrow key. + */ + private menuPaneRefs: Map = new Map() + private onItemClicked = ( depth: number, item: MenuItem, @@ -127,6 +133,13 @@ export class AppMenu extends React.Component { menu.withClosedMenu(this.props.state[depth]) ) + // Restore focus to the parent menu pane to prevent the menu bar + // from detecting focus loss and closing the entire menu + const parentPane = this.menuPaneRefs.get(depth - 1) + if (parentPane) { + parentPane.focus() + } + event.preventDefault() } } else if (event.key === 'ArrowRight') { @@ -211,6 +224,10 @@ export class AppMenu extends React.Component { } } + private onMenuPaneRef = (depth: number, element: HTMLDivElement | null) => { + this.menuPaneRefs.set(depth, element) + } + private renderMenuPane(depth: number, menu: IMenu): JSX.Element { // If the menu doesn't have an id it's the root menu const key = menu.id || '@' @@ -230,6 +247,7 @@ export class AppMenu extends React.Component { enableAccessKeyNavigation={this.props.enableAccessKeyNavigation} onClearSelection={this.onClearSelection} ariaLabelledby={this.props.ariaLabelledby} + onRef={this.onMenuPaneRef} /> ) } diff --git a/app/src/ui/app-menu/menu-pane.tsx b/app/src/ui/app-menu/menu-pane.tsx index 5c3397b912f..154855c9b14 100644 --- a/app/src/ui/app-menu/menu-pane.tsx +++ b/app/src/ui/app-menu/menu-pane.tsx @@ -98,9 +98,16 @@ interface IMenuPaneProps { readonly allowFirstCharacterNavigation?: boolean readonly renderLabel?: (item: MenuItem) => JSX.Element | undefined + + /** Optional callback for capturing a ref to the menu pane element */ + readonly onRef?: (depth: number, element: HTMLDivElement | null) => void } export class MenuPane extends React.Component { + private onMenuPaneRef = (element: HTMLDivElement | null) => { + this.props.onRef?.(this.props.depth, element) + } + private onRowClick = ( item: MenuItem, event: React.MouseEvent @@ -258,6 +265,7 @@ export class MenuPane extends React.Component { */ // eslint-disable-next-line jsx-a11y/no-static-element-interactions
Date: Fri, 31 Oct 2025 14:13:28 +0100 Subject: [PATCH 077/865] Remove feature flag for recurse submodules Eliminated the enableRecurseSubmodulesFlag feature flag and updated fetch and pull operations to always use the --recurse-submodules option. This simplifies submodule handling and removes unnecessary conditional logic. --- app/src/lib/feature-flag.ts | 5 ----- app/src/lib/git/checkout.ts | 5 ----- app/src/lib/git/fetch.ts | 5 +---- app/src/lib/git/pull.ts | 3 +-- 4 files changed, 2 insertions(+), 16 deletions(-) diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index d249d067484..bce5002ade3 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -41,11 +41,6 @@ function enableBetaFeatures(): boolean { export const enableTestMenuItems = () => enableDevelopmentFeatures() || __RELEASE_CHANNEL__ === 'test' -/** Should git pass `--recurse-submodules` when performing operations? */ -export function enableRecurseSubmodulesFlag(): boolean { - return true -} - export function enableReadmeOverwriteWarning(): boolean { return enableBetaFeatures() } diff --git a/app/src/lib/git/checkout.ts b/app/src/lib/git/checkout.ts index b4a8888e39e..3cd2ff2d999 100644 --- a/app/src/lib/git/checkout.ts +++ b/app/src/lib/git/checkout.ts @@ -8,7 +8,6 @@ import { IGitOutput, } from '../progress' import { AuthenticationErrors } from './authentication' -import { enableRecurseSubmodulesFlag } from '../feature-flag' import { envForRemoteOperation, getFallbackUrlForProxyResolve, @@ -117,10 +116,6 @@ async function updateSubmodulesAfterCheckout( progressCallback: ProgressCallback | undefined, allowFileProtocol: boolean ): Promise { - if (!enableRecurseSubmodulesFlag()) { - return - } - const opts: IGitStringExecutionOptions = { env: await envForRemoteOperation( getFallbackUrlForProxyResolve(repository, currentRemote) diff --git a/app/src/lib/git/fetch.ts b/app/src/lib/git/fetch.ts index 408b29015a3..dc131ed6146 100644 --- a/app/src/lib/git/fetch.ts +++ b/app/src/lib/git/fetch.ts @@ -2,7 +2,6 @@ import { git, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { IFetchProgress } from '../../models/progress' import { FetchProgressParser, executionOptionsWithProgress } from '../progress' -import { enableRecurseSubmodulesFlag } from '../feature-flag' import { IRemote } from '../../models/remote' import { ITrackingBranch } from '../../models/branch' import { envForRemoteOperation } from './environment' @@ -15,9 +14,7 @@ async function getFetchArgs( 'fetch', ...(progressCallback ? ['--progress'] : []), '--prune', - ...(enableRecurseSubmodulesFlag() - ? ['--recurse-submodules=on-demand'] - : []), + '--recurse-submodules=on-demand', remote, ] } diff --git a/app/src/lib/git/pull.ts b/app/src/lib/git/pull.ts index 3be7ab34ec2..d71dbf3d03d 100644 --- a/app/src/lib/git/pull.ts +++ b/app/src/lib/git/pull.ts @@ -2,7 +2,6 @@ import { git, gitRebaseArguments, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { IPullProgress } from '../../models/progress' import { PullProgressParser, executionOptionsWithProgress } from '../progress' -import { enableRecurseSubmodulesFlag } from '../feature-flag' import { IRemote } from '../../models/remote' import { envForRemoteOperation } from './environment' import { getConfigValue } from './config' @@ -16,7 +15,7 @@ async function getPullArgs( ...gitRebaseArguments(), 'pull', ...(await getDefaultPullDivergentBranchArguments(repository)), - ...(enableRecurseSubmodulesFlag() ? ['--recurse-submodules'] : []), + '--recurse-submodules', ...(progressCallback ? ['--progress'] : []), remote, ] From 7c623542b7d1b9bd276097d1ab459a746965b238 Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Fri, 31 Oct 2025 14:44:25 +0100 Subject: [PATCH 078/865] Remove obsolete submodule cleanup test Deleted the test for cleaning up submodules that no longer exist from checkout-test.ts. This test is no longer needed or relevant to current functionality. --- app/test/unit/git/checkout-test.ts | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/app/test/unit/git/checkout-test.ts b/app/test/unit/git/checkout-test.ts index f34c3ff551d..ae130604959 100644 --- a/app/test/unit/git/checkout-test.ts +++ b/app/test/unit/git/checkout-test.ts @@ -127,30 +127,6 @@ describe('git/checkout', () => { }) describe('with submodules', () => { - it('cleans up an submodule that no longer exists', async t => { - const path = await setupFixtureRepository(t, 'test-submodule-checkouts') - const repository = new Repository(path, -1, null, false) - - // put the repository into a known good state - await exec( - ['checkout', 'add-private-repo', '-f', '--recurse-submodules'], - path - ) - - const branches = await getBranches(repository) - const masterBranch = branches.find(b => b.name === 'master') - - if (masterBranch == null) { - throw new Error(`Could not find branch: 'master'`) - } - - await checkoutBranch(repository, masterBranch, null) - - const status = await getStatusOrThrow(repository) - - assert.equal(status.workingDirectory.files.length, 0) - }) - it('updates a changed submodule reference', async t => { const path = await setupFixtureRepository(t, 'test-submodule-checkouts') const repository = new Repository(path, -1, null, false) From 92b0dbe2d0d67267daeab453ae25760c3416d04d Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Fri, 31 Oct 2025 14:55:48 +0100 Subject: [PATCH 079/865] Add submodule update after pull and related tests Enhances the pull logic to update submodules after pulling from a remote, including progress reporting and file protocol support. Adds unit tests to verify submodule initialization, reference updates, and handling of pulls with no submodule changes. --- app/src/lib/git/pull.ts | 148 +++++++++++++++++++- app/test/unit/git/pull/pull-test.ts | 204 ++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 app/test/unit/git/pull/pull-test.ts diff --git a/app/src/lib/git/pull.ts b/app/src/lib/git/pull.ts index d71dbf3d03d..4d571cb894e 100644 --- a/app/src/lib/git/pull.ts +++ b/app/src/lib/git/pull.ts @@ -1,11 +1,32 @@ import { git, gitRebaseArguments, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { IPullProgress } from '../../models/progress' -import { PullProgressParser, executionOptionsWithProgress } from '../progress' +import { + PullProgressParser, + executionOptionsWithProgress, + IGitOutput, +} from '../progress' import { IRemote } from '../../models/remote' -import { envForRemoteOperation } from './environment' +import { + envForRemoteOperation, + getFallbackUrlForProxyResolve, +} from './environment' import { getConfigValue } from './config' +const PullStepWeight = 0.9 + +function clampProgress( + minimum: number, + maximum: number, + progressCallback: (progress: IPullProgress) => void +): (progress: IPullProgress) => void { + return (progress: IPullProgress) => + progressCallback({ + ...progress, + value: minimum + progress.value * (maximum - minimum), + }) +} + async function getPullArgs( repository: Repository, remote: string, @@ -15,12 +36,110 @@ async function getPullArgs( ...gitRebaseArguments(), 'pull', ...(await getDefaultPullDivergentBranchArguments(repository)), - '--recurse-submodules', ...(progressCallback ? ['--progress'] : []), remote, ] } +/** + * Update submodules after a pull operation. + * + * @param repository - The repository in which to update submodules + * @param remote - The remote that was pulled from + * @param progressCallback - An optional function which will be invoked + * with information about the current progress + * of the submodule update operation. + */ +async function updateSubmodulesAfterPull( + repository: Repository, + remote: IRemote, + progressCallback: ((progress: IPullProgress) => void) | undefined, + allowFileProtocol: boolean +): Promise { + const opts: IGitStringExecutionOptions = { + env: await envForRemoteOperation( + getFallbackUrlForProxyResolve(repository, remote) + ), + } + + const args = [ + ...(allowFileProtocol ? ['-c', 'protocol.file.allow=always'] : []), + 'submodule', + 'update', + '--init', + '--recursive', + ] + + if (!progressCallback) { + await git(args, repository.path, 'updateSubmodules', opts) + return + } + + // Initial progress + const title = `Pulling ${remote.name}` + progressCallback({ + kind: 'pull', + title, + description: 'Updating submodules', + value: 0, + remote: remote.name, + }) + + const kind = 'pull' + + let submoduleEventCount = 0 + + const progressOpts = await executionOptionsWithProgress( + { ...opts, trackLFSProgress: true }, + { + parse(line: string): IGitOutput { + if ( + line.match(/^Submodule path (.)+?: checked out /) || + line.startsWith('Cloning into ') + ) { + submoduleEventCount += 1 + } + + return { + kind: 'context', + text: `Updating submodules: ${line}`, + // Math taken from https://math.stackexchange.com/a/2323106 + // We do this to fake a progress that slows down as we process more + // events, as we don't know how many submodules there are upfront, or + // what does git have to do with them (cloning, just checking them + // out...) + percent: 1 - Math.exp(-submoduleEventCount * 0.25), + } + }, + }, + progress => { + const description = + progress.kind === 'progress' ? progress.details.text : progress.text + + const value = progress.percent + + progressCallback({ + kind, + title, + description, + value, + remote: remote.name, + }) + } + ) + + await git(args, repository.path, 'updateSubmodules', progressOpts) + + // Final progress + progressCallback({ + kind, + title, + description: 'Submodules updated', + value: 1, + remote: remote.name, + }) +} + /** * Pull from the specified remote. * @@ -37,16 +156,21 @@ async function getPullArgs( export async function pull( repository: Repository, remote: IRemote, - progressCallback?: (progress: IPullProgress) => void + progressCallback?: (progress: IPullProgress) => void, + allowFileProtocol: boolean = false ): Promise { let opts: IGitStringExecutionOptions = { - env: await envForRemoteOperation(remote.url), + env: await envForRemoteOperation( + getFallbackUrlForProxyResolve(repository, remote) + ), } if (progressCallback) { const title = `Pulling ${remote.name}` const kind = 'pull' + const clampedCallback = clampProgress(0, PullStepWeight, progressCallback) + opts = await executionOptionsWithProgress( { ...opts, trackLFSProgress: true }, new PullProgressParser(), @@ -66,7 +190,7 @@ export async function pull( const value = progress.percent - progressCallback({ + clampedCallback({ kind, title, description, @@ -77,11 +201,21 @@ export async function pull( ) // Initial progress - progressCallback({ kind, title, value: 0, remote: remote.name }) + clampedCallback({ kind, title, value: 0, remote: remote.name }) } const args = await getPullArgs(repository, remote.name, progressCallback) await git(args, repository.path, 'pull', opts) + + // Update submodules after pull + await updateSubmodulesAfterPull( + repository, + remote, + progressCallback + ? clampProgress(PullStepWeight, 1, progressCallback) + : undefined, + allowFileProtocol + ) } /** diff --git a/app/test/unit/git/pull/pull-test.ts b/app/test/unit/git/pull/pull-test.ts new file mode 100644 index 00000000000..6dc5378076a --- /dev/null +++ b/app/test/unit/git/pull/pull-test.ts @@ -0,0 +1,204 @@ +import { describe, it, TestContext } from 'node:test' +import assert from 'node:assert' +import * as Path from 'path' +import * as FSE from 'fs-extra' +import { setupEmptyRepository } from '../../../helpers/repositories' +import { pull } from '../../../../src/lib/git' +import { IRemote } from '../../../../src/models/remote' +import { exec } from 'dugite' +import { Repository } from '../../../../src/models/repository' +import { + cloneRepository, + makeCommit, +} from '../../../helpers/repository-scaffolding' + +async function setupRepositoryWithSubmodule( + t: TestContext +): Promise<{ parent: Repository; submodule: Repository }> { + const parent = await setupEmptyRepository(t) + const submodule = await setupEmptyRepository(t) + + // Add commits to submodule + await makeCommit(submodule, { + commitMessage: 'Initial commit in submodule', + entries: [{ path: 'submodule-file.txt', contents: 'hello from submodule' }], + }) + + await makeCommit(submodule, { + commitMessage: 'Second commit in submodule', + entries: [{ path: 'submodule-file.txt', contents: 'updated content' }], + }) + + // Add commits to parent + await makeCommit(parent, { + commitMessage: 'Initial commit in parent', + entries: [{ path: 'README.md', contents: '# Parent repo' }], + }) + + // Add submodule to parent + await exec( + [ + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + submodule.path, + 'test-submodule', + ], + parent.path + ) + + await exec(['commit', '-m', 'Add submodule'], parent.path) + + return { parent, submodule } +} + +describe('git/pull', () => { + describe('with submodules', () => { + it('initializes an uninitialized submodule when pulling from a remote with changes', async t => { + // Setup: Create a parent repo with a submodule, then clone it + const { parent } = await setupRepositoryWithSubmodule(t) + + // Clone the parent repository + const cloned = await cloneRepository(t, parent) + + // Make a change in the parent (original) repo + await makeCommit(parent, { + commitMessage: 'Update README', + entries: [{ path: 'README.md', contents: '# Updated Parent repo' }], + }) + + const remote: IRemote = { + name: 'origin', + url: parent.path, + } + + // Deinitialize the submodule in the cloned repo + const submodulePath = Path.join(cloned.path, 'test-submodule') + await exec(['submodule', 'deinit', '-f', 'test-submodule'], cloned.path) + + // Remove the submodule directory + await FSE.remove(submodulePath) + + // Verify the submodule is not initialized + const submoduleStatus = await exec(['submodule', 'status'], cloned.path) + assert.ok( + submoduleStatus.stdout.includes('-'), + 'Submodule should be uninitialized (starts with -)' + ) + + // Now pull with allowFileProtocol=true + await pull(cloned, remote, undefined, true) + + // Verify the submodule is initialized and has the correct commits + const submoduleGitPath = Path.join(submodulePath, '.git') + + // Check that submodule .git exists (either as file or directory) + const submoduleGitExists = await FSE.pathExists(submoduleGitPath) + assert.equal( + submoduleGitExists, + true, + 'Submodule .git should exist after pull' + ) + + // Verify submodule has two commits + const submoduleLog = await exec(['log', '--oneline'], submodulePath) + assert.equal( + submoduleLog.stdout.trim().split('\n').length, + 2, + 'Submodule should have two commits' + ) + }) + + it('updates submodule references after pulling changes', async t => { + // Setup: Create parent with submodule, clone it + const { parent, submodule } = await setupRepositoryWithSubmodule(t) + + const cloned = await cloneRepository(t, parent) + + // Initialize submodules in the cloned repo + await exec( + ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], + cloned.path + ) + + const submodulePath = Path.join(cloned.path, 'test-submodule') + + // Verify initial state + const initialLog = await exec(['log', '--oneline'], submodulePath) + const initialCommitCount = initialLog.stdout.trim().split('\n').length + assert.equal(initialCommitCount, 2, 'Should start with 2 commits') + + // Add a new commit to the submodule + await makeCommit(submodule, { + commitMessage: 'Third commit in submodule', + entries: [{ path: 'another-file.txt', contents: 'more content' }], + }) + + // Update the submodule reference in parent and commit + await exec( + ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--remote'], + parent.path + ) + await exec(['add', 'test-submodule'], parent.path) + await exec(['commit', '-m', 'Update submodule reference'], parent.path) + + const remote: IRemote = { + name: 'origin', + url: parent.path, + } + + // Pull the changes + await pull(cloned, remote, undefined, true) + + // Verify submodule was updated to the new reference + const finalLog = await exec(['log', '--oneline'], submodulePath) + const finalCommitCount = finalLog.stdout.trim().split('\n').length + + assert.equal( + finalCommitCount, + 3, + 'Submodule should now have 3 commits after update' + ) + }) + + it('handles pull when there are no submodule changes', async t => { + const { parent } = await setupRepositoryWithSubmodule(t) + const cloned = await cloneRepository(t, parent) + + // Initialize submodules + await exec( + ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], + cloned.path + ) + + // Make a change that doesn't affect submodules + await makeCommit(parent, { + commitMessage: 'Update README again', + entries: [{ path: 'README.md', contents: '# Another update' }], + }) + + const remote: IRemote = { + name: 'origin', + url: parent.path, + } + + const submodulePath = Path.join(cloned.path, 'test-submodule') + const beforeLog = await exec(['log', '--oneline'], submodulePath) + const beforeCount = beforeLog.stdout.trim().split('\n').length + + // Pull should succeed without errors + await pull(cloned, remote, undefined, true) + + // Submodule should remain unchanged + const afterLog = await exec(['log', '--oneline'], submodulePath) + const afterCount = afterLog.stdout.trim().split('\n').length + + assert.equal( + afterCount, + beforeCount, + 'Submodule commits should remain unchanged' + ) + }) + }) +}) From a9a378eafca146c3142d38ae9983074d53f10f3c Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Fri, 31 Oct 2025 15:18:36 +0100 Subject: [PATCH 080/865] Refactor submodule update logic for git operations Consolidates submodule update logic into a generic updateSubmodulesAfterOperation function in submodule.ts, replacing operation-specific implementations in checkout.ts and pull.ts. Also moves and generalizes clampProgress to progress.ts for reuse. This improves code maintainability and reduces duplication. --- app/src/lib/git/checkout.ts | 145 +++++------------------------------ app/src/lib/git/pull.ts | 126 ++---------------------------- app/src/lib/git/submodule.ts | 121 ++++++++++++++++++++++++++++- app/src/models/progress.ts | 16 ++++ 4 files changed, 164 insertions(+), 244 deletions(-) diff --git a/app/src/lib/git/checkout.ts b/app/src/lib/git/checkout.ts index 3cd2ff2d999..6b2a1a1b604 100644 --- a/app/src/lib/git/checkout.ts +++ b/app/src/lib/git/checkout.ts @@ -1,11 +1,10 @@ import { git, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { Branch, BranchType } from '../../models/branch' -import { ICheckoutProgress } from '../../models/progress' +import { clampProgress, ICheckoutProgress } from '../../models/progress' import { CheckoutProgressParser, executionOptionsWithProgress, - IGitOutput, } from '../progress' import { AuthenticationErrors } from './authentication' import { @@ -16,23 +15,12 @@ import { WorkingDirectoryFileChange } from '../../models/status' import { ManualConflictResolution } from '../../models/manual-conflict-resolution' import { CommitOneLine, shortenSHA } from '../../models/commit' import { IRemote } from '../../models/remote' +import { updateSubmodulesAfterOperation } from './submodule' export type ProgressCallback = (progress: ICheckoutProgress) => void const CheckoutStepWeight = 0.9 -function clampProgress( - minimum: number, - maximum: number, - progressCallback: ProgressCallback -): ProgressCallback { - return (progress: ICheckoutProgress) => - progressCallback({ - ...progress, - value: minimum + progress.value * (maximum - minimum), - }) -} - function getCheckoutArgs(progressCallback?: ProgressCallback) { return ['checkout', ...(progressCallback ? ['--progress'] : [])] } @@ -97,109 +85,6 @@ async function getCheckoutOpts( ) } -/** - * Update submodules after a checkout operation. - * - * @param repository - The repository in which to update submodules - * @param title - The title to use for progress reporting - * @param target - The target branch/commit being checked out - * @param currentRemote - The current remote for environment setup - * @param progressCallback - An optional function which will be invoked - * with information about the current progress - * of the submodule update operation. - */ -async function updateSubmodulesAfterCheckout( - repository: Repository, - title: string, - target: string, - currentRemote: IRemote | null, - progressCallback: ProgressCallback | undefined, - allowFileProtocol: boolean -): Promise { - const opts: IGitStringExecutionOptions = { - env: await envForRemoteOperation( - getFallbackUrlForProxyResolve(repository, currentRemote) - ), - expectedErrors: AuthenticationErrors, - } - - const args = [ - ...(allowFileProtocol ? ['-c', 'protocol.file.allow=always'] : []), - 'submodule', - 'update', - '--init', - '--recursive', - ] - - if (!progressCallback) { - await git(args, repository.path, 'updateSubmodules', opts) - return - } - - // Initial progress - progressCallback({ - kind: 'checkout', - title, - description: 'Updating submodules', - value: 0, - target, - }) - - const kind = 'checkout' - - let submoduleEventCount = 0 - - const progressOpts = await executionOptionsWithProgress( - { ...opts, trackLFSProgress: true }, - { - parse(line: string): IGitOutput { - if ( - line.match(/^Submodule path (.)+?: checked out /) || - line.startsWith('Cloning into ') - ) { - submoduleEventCount += 1 - } - - return { - kind: 'context', - text: `Updating submodules: ${line}`, - // Math taken from https://math.stackexchange.com/a/2323106 - // We do this to fake a progress that slows down as we process more - // events, as we don't know how many submodules there are upfront, or - // what does git have to do with them (cloning, just checking them - // out...) - percent: 1 - Math.exp(-submoduleEventCount * 0.25), - } - }, - }, - progress => { - const description = - progress.kind === 'progress' ? progress.details.text : progress.text - - const value = progress.percent - - progressCallback({ - kind, - title, - description, - value, - target, - }) - } - ) - - await git(args, repository.path, 'updateSubmodules', progressOpts) - - // Final progress - progressCallback({ - kind, - title, - description: 'Submodules updated', - value: 1, - target, - }) -} - /** * Check out the given branch. * @@ -239,14 +124,19 @@ export async function checkoutBranch( await git(args, repository.path, 'checkoutBranch', opts) // Update submodules after checkout - await updateSubmodulesAfterCheckout( + await updateSubmodulesAfterOperation( repository, - title, - branch.name, currentRemote, progressCallback - ? clampProgress(CheckoutStepWeight, 1, progressCallback) + ? clampProgress( + CheckoutStepWeight, + 1, + progressCallback + ) : undefined, + 'checkout', + title, + branch.name, allowFileProtocol ) @@ -295,14 +185,19 @@ export async function checkoutCommit( await git(args, repository.path, 'checkoutCommit', opts) // Update submodules after checkout - await updateSubmodulesAfterCheckout( + await updateSubmodulesAfterOperation( repository, - title, - target, currentRemote, progressCallback - ? clampProgress(CheckoutStepWeight, 1, progressCallback) + ? clampProgress( + CheckoutStepWeight, + 1, + progressCallback + ) : undefined, + 'checkout', + title, + target, allowFileProtocol ) diff --git a/app/src/lib/git/pull.ts b/app/src/lib/git/pull.ts index 4d571cb894e..4a88fc7a6b3 100644 --- a/app/src/lib/git/pull.ts +++ b/app/src/lib/git/pull.ts @@ -1,32 +1,17 @@ import { git, gitRebaseArguments, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' -import { IPullProgress } from '../../models/progress' -import { - PullProgressParser, - executionOptionsWithProgress, - IGitOutput, -} from '../progress' +import { clampProgress, IPullProgress } from '../../models/progress' +import { PullProgressParser, executionOptionsWithProgress } from '../progress' import { IRemote } from '../../models/remote' import { envForRemoteOperation, getFallbackUrlForProxyResolve, } from './environment' import { getConfigValue } from './config' +import { updateSubmodulesAfterOperation } from './submodule' const PullStepWeight = 0.9 -function clampProgress( - minimum: number, - maximum: number, - progressCallback: (progress: IPullProgress) => void -): (progress: IPullProgress) => void { - return (progress: IPullProgress) => - progressCallback({ - ...progress, - value: minimum + progress.value * (maximum - minimum), - }) -} - async function getPullArgs( repository: Repository, remote: string, @@ -41,105 +26,6 @@ async function getPullArgs( ] } -/** - * Update submodules after a pull operation. - * - * @param repository - The repository in which to update submodules - * @param remote - The remote that was pulled from - * @param progressCallback - An optional function which will be invoked - * with information about the current progress - * of the submodule update operation. - */ -async function updateSubmodulesAfterPull( - repository: Repository, - remote: IRemote, - progressCallback: ((progress: IPullProgress) => void) | undefined, - allowFileProtocol: boolean -): Promise { - const opts: IGitStringExecutionOptions = { - env: await envForRemoteOperation( - getFallbackUrlForProxyResolve(repository, remote) - ), - } - - const args = [ - ...(allowFileProtocol ? ['-c', 'protocol.file.allow=always'] : []), - 'submodule', - 'update', - '--init', - '--recursive', - ] - - if (!progressCallback) { - await git(args, repository.path, 'updateSubmodules', opts) - return - } - - // Initial progress - const title = `Pulling ${remote.name}` - progressCallback({ - kind: 'pull', - title, - description: 'Updating submodules', - value: 0, - remote: remote.name, - }) - - const kind = 'pull' - - let submoduleEventCount = 0 - - const progressOpts = await executionOptionsWithProgress( - { ...opts, trackLFSProgress: true }, - { - parse(line: string): IGitOutput { - if ( - line.match(/^Submodule path (.)+?: checked out /) || - line.startsWith('Cloning into ') - ) { - submoduleEventCount += 1 - } - - return { - kind: 'context', - text: `Updating submodules: ${line}`, - // Math taken from https://math.stackexchange.com/a/2323106 - // We do this to fake a progress that slows down as we process more - // events, as we don't know how many submodules there are upfront, or - // what does git have to do with them (cloning, just checking them - // out...) - percent: 1 - Math.exp(-submoduleEventCount * 0.25), - } - }, - }, - progress => { - const description = - progress.kind === 'progress' ? progress.details.text : progress.text - - const value = progress.percent - - progressCallback({ - kind, - title, - description, - value, - remote: remote.name, - }) - } - ) - - await git(args, repository.path, 'updateSubmodules', progressOpts) - - // Final progress - progressCallback({ - kind, - title, - description: 'Submodules updated', - value: 1, - remote: remote.name, - }) -} - /** * Pull from the specified remote. * @@ -208,12 +94,16 @@ export async function pull( await git(args, repository.path, 'pull', opts) // Update submodules after pull - await updateSubmodulesAfterPull( + const title = `Pulling ${remote.name}` + await updateSubmodulesAfterOperation( repository, remote, progressCallback ? clampProgress(PullStepWeight, 1, progressCallback) : undefined, + 'pull', + title, + remote.name, allowFileProtocol ) } diff --git a/app/src/lib/git/submodule.ts b/app/src/lib/git/submodule.ts index 7cdaef26d9b..6b68b8413e8 100644 --- a/app/src/lib/git/submodule.ts +++ b/app/src/lib/git/submodule.ts @@ -1,9 +1,128 @@ import * as Path from 'path' -import { git } from './core' +import { git, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { SubmoduleEntry } from '../../models/submodule' import { pathExists } from '../../ui/lib/path-exists' +import { executionOptionsWithProgress, IGitOutput } from '../progress' +import { + envForRemoteOperation, + getFallbackUrlForProxyResolve, +} from './environment' +import { AuthenticationErrors } from './authentication' +import { IRemote } from '../../models/remote' +import { Progress } from '../../models/progress' + +/** + * Update submodules after a git operation. + * + * @param repository - The repository in which to update submodules + * @param remote - The remote for environment setup (can be null) + * @param progressCallback - An optional function which will be invoked + * with information about the current progress + * of the submodule update operation. + * @param progressKind - The kind of progress event ('checkout', 'pull', etc.) + * @param title - The title to use for progress reporting + * @param targetOrRemote - The target (for checkout) or remote name (for pull) + * @param allowFileProtocol - Whether to allow file:// protocol for submodules + */ +export async function updateSubmodulesAfterOperation( + repository: Repository, + remote: IRemote | null, + progressCallback: ((progress: T) => void) | undefined, + progressKind: T['kind'], + title: string, + targetOrRemote: string, + allowFileProtocol: boolean +): Promise { + const opts: IGitStringExecutionOptions = { + env: await envForRemoteOperation( + getFallbackUrlForProxyResolve(repository, remote) + ), + expectedErrors: AuthenticationErrors, + } + + const args = [ + ...(allowFileProtocol ? ['-c', 'protocol.file.allow=always'] : []), + 'submodule', + 'update', + '--init', + '--recursive', + ] + + if (!progressCallback) { + await git(args, repository.path, 'updateSubmodules', opts) + return + } + + // Initial progress + progressCallback({ + kind: progressKind, + title, + description: 'Updating submodules', + value: 0, + // Add the target or remote field based on the progress kind + ...(progressKind === 'checkout' + ? { target: targetOrRemote } + : { remote: targetOrRemote }), + } as T) + + let submoduleEventCount = 0 + + const progressOpts = await executionOptionsWithProgress( + { ...opts, trackLFSProgress: true }, + { + parse(line: string): IGitOutput { + if ( + line.match(/^Submodule path (.)+?: checked out /) || + line.startsWith('Cloning into ') + ) { + submoduleEventCount += 1 + } + + return { + kind: 'context', + text: `Updating submodules: ${line}`, + // Math taken from https://math.stackexchange.com/a/2323106 + // We do this to fake a progress that slows down as we process more + // events, as we don't know how many submodules there are upfront, or + // what does git have to do with them (cloning, just checking them + // out...) + percent: 1 - Math.exp(-submoduleEventCount * 0.25), + } + }, + }, + progress => { + const description = + progress.kind === 'progress' ? progress.details.text : progress.text + + const value = progress.percent + + progressCallback({ + kind: progressKind, + title, + description, + value, + ...(progressKind === 'checkout' + ? { target: targetOrRemote } + : { remote: targetOrRemote }), + } as T) + } + ) + + await git(args, repository.path, 'updateSubmodules', progressOpts) + + // Final progress + progressCallback({ + kind: progressKind, + title, + description: 'Submodules updated', + value: 1, + ...(progressKind === 'checkout' + ? { target: targetOrRemote } + : { remote: targetOrRemote }), + } as T) +} export async function listSubmodules( repository: Repository diff --git a/app/src/models/progress.ts b/app/src/models/progress.ts index 28802559d4e..14e9c2a5614 100644 --- a/app/src/models/progress.ts +++ b/app/src/models/progress.ts @@ -122,3 +122,19 @@ export type Progress = | IPushProgress | IRevertProgress | IMultiCommitOperationProgress + +/** + * Clamps progress values between minimum and maximum. + * Useful for reserving portions of progress reporting for different stages. + */ +export function clampProgress( + minimum: number, + maximum: number, + progressCallback: (progress: T) => void +): (progress: T) => void { + return (progress: T) => + progressCallback({ + ...progress, + value: minimum + progress.value * (maximum - minimum), + }) +} From f244a199229c245723b8e7b3d40edf04fb63cc21 Mon Sep 17 00:00:00 2001 From: zeki <153834382+zekariasasaminew@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:38:03 -0500 Subject: [PATCH 081/865] Refactor popover close logic in CommitMessageAvatar --- app/src/ui/changes/commit-message-avatar.tsx | 27 +------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/app/src/ui/changes/commit-message-avatar.tsx b/app/src/ui/changes/commit-message-avatar.tsx index 60f00f65da4..8b71afecc19 100644 --- a/app/src/ui/changes/commit-message-avatar.tsx +++ b/app/src/ui/changes/commit-message-avatar.tsx @@ -104,7 +104,6 @@ export class CommitMessageAvatar extends React.Component< > { private avatarButtonRef: HTMLButtonElement | null = null private warningBadgeRef = React.createRef() - private documentClickListener: (event: MouseEvent) => void public constructor(props: ICommitMessageAvatarProps) { super(props) @@ -115,31 +114,6 @@ export class CommitMessageAvatar extends React.Component< isGitConfigLocal: false, } this.determineGitConfigLocation() - - // Create the document click listener - this.documentClickListener = (event: MouseEvent) => { - if (this.state.isPopoverOpen && event.target instanceof Element) { - // Check if click is on app menu bar or its descendants - const appMenuBar = document.getElementById('app-menu-bar') - if ( - appMenuBar && - (appMenuBar.contains(event.target) || appMenuBar === event.target) - ) { - // Close popover when clicking on app menu - this.closePopover() - } - } - } - } - - public componentDidMount() { - // Add global click listener to detect app menu clicks - document.addEventListener('click', this.documentClickListener, true) // Use capture phase - } - - public componentWillUnmount() { - // Remove global click listener - document.removeEventListener('click', this.documentClickListener, true) } public componentDidUpdate(prevProps: ICommitMessageAvatarProps) { @@ -450,6 +424,7 @@ export class CommitMessageAvatar extends React.Component< } anchorPosition={PopoverAnchorPosition.RightBottom} decoration={PopoverDecoration.Balloon} + onMousedownOutside={this.closePopover} onClickOutside={this.closePopover} ariaLabelledby="commit-avatar-popover-header" > From d153ac2be8f9948a69df482b33e4c7409af48244 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 3 Nov 2025 13:56:56 +0100 Subject: [PATCH 082/865] Proof of concept hook runner --- app/src/lib/feature-flag.ts | 2 + app/src/lib/git/core.ts | 194 +++++++++++++++------------- app/src/lib/hooks/get-repo-hooks.ts | 43 ++++++ app/src/lib/hooks/with-hooks-env.ts | 192 +++++++++++++++++++++++++++ package.json | 1 + script/build.ts | 10 ++ tsconfig.json | 2 +- yarn.lock | 5 + 8 files changed, 357 insertions(+), 92 deletions(-) create mode 100644 app/src/lib/hooks/get-repo-hooks.ts create mode 100644 app/src/lib/hooks/with-hooks-env.ts diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index d249d067484..5325758459f 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -120,3 +120,5 @@ export const enableCommitMessageGeneration = (account: Account) => { export function enableAccessibleListToolTips(): boolean { return enableBetaFeatures() } + +export const enableHooksEnvironment = enableDevelopmentFeatures diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 11e9036c075..5eec52a182c 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -12,11 +12,11 @@ import { assertNever } from '../fatal-error' import * as GitPerf from '../../ui/lib/git-perf' import * as Path from 'path' import { isErrnoException } from '../errno-exception' -import { merge } from '../merge' import { withTrampolineEnv } from '../trampoline/trampoline-environment' import { createTailStream } from './create-tail-stream' import { createTerminalStream } from '../create-terminal-stream' import { kStringMaxLength } from 'buffer' +import { withHooksEnv } from '../hooks/with-hooks-env' export const coerceToString = ( value: string | Buffer, @@ -239,102 +239,114 @@ export async function git( options?.processCallback?.(process) } - return withTrampolineEnv( - async env => { - const combinedEnv = merge(opts.env, env) - - // Explicitly set TERM to 'dumb' so that if Desktop was launched - // from a terminal or if the system environment variables - // have TERM set Git won't consider us as a smart terminal. - // See https://github.com/git/git/blob/a7312d1a2/editor.c#L11-L15 - opts.env = { TERM: 'dumb', ...combinedEnv } - - const commandName = `${name}: git ${args.join(' ')}` - - const result = await GitPerf.measure(commandName, () => - exec(args, path, opts) - ).catch(err => { - // If this is an exception thrown by Node.js (as opposed to - // dugite) let's keep the salient details but include the name of - // the operation. - if (isErrnoException(err)) { - throw new Error(`Failed to execute ${name}: ${err.code}`) - } - - if (isMaxBufferExceededError(err)) { - throw new ExecError( - `${err.message} for ${name}`, - err.stdout, - err.stderr, - // Dugite stores the original Node error in the cause property, by - // passing that along we ensure that all we're doing here is - // changing the error message (and capping the stack but that's - // okay since we know exactly where this error is coming from). - // The null coalescing here is a safety net in case dugite's - // behavior changes from underneath us. - err.cause ?? err + return withHooksEnv( + hooksEnv => + withTrampolineEnv( + async env => { + const commandName = `${name}: git ${args.join(' ')}` + + const result = await GitPerf.measure(commandName, () => + exec(args, path, { + ...opts, + env: { + // Explicitly set TERM to 'dumb' so that if Desktop was launched + // from a terminal or if the system environment variables + // have TERM set Git won't consider us as a smart terminal. + // See https://github.com/git/git/blob/a7312d1a2/editor.c#L11-L15 + TERM: 'dumb', + ...opts.env, + ...hooksEnv, + ...env, + }, + }) + ).catch(err => { + // If this is an exception thrown by Node.js (as opposed to + // dugite) let's keep the salient details but include the name of + // the operation. + if (isErrnoException(err)) { + throw new Error(`Failed to execute ${name}: ${err.code}`) + } + + if (isMaxBufferExceededError(err)) { + throw new ExecError( + `${err.message} for ${name}`, + err.stdout, + err.stderr, + // Dugite stores the original Node error in the cause property, by + // passing that along we ensure that all we're doing here is + // changing the error message (and capping the stack but that's + // okay since we know exactly where this error is coming from). + // The null coalescing here is a safety net in case dugite's + // behavior changes from underneath us. + err.cause ?? err + ) + } + + throw err + }) + + const exitCode = result.exitCode + + let gitError: DugiteError | null = null + const acceptableExitCode = opts.successExitCodes + ? opts.successExitCodes.has(exitCode) + : false + if (!acceptableExitCode) { + gitError = parseError(coerceToString(result.stderr)) + if (gitError === null) { + gitError = parseError(coerceToString(result.stdout)) + } + } + + const gitErrorDescription = + gitError !== null + ? getDescriptionForError(gitError, coerceToString(result.stderr)) + : null + const gitResult = { + ...result, + gitError, + gitErrorDescription, + path, + } + + let acceptableError = true + if (gitError !== null && opts.expectedErrors) { + acceptableError = opts.expectedErrors.has(gitError) + } + + if ((gitError !== null && acceptableError) || acceptableExitCode) { + return gitResult + } + + // The caller should either handle this error, or expect that exit code. + const errorMessage = new Array() + errorMessage.push( + `\`git ${args.join( + ' ' + )}\` exited with an unexpected code: ${exitCode}.` ) - } - - throw err - }) - - const exitCode = result.exitCode - - let gitError: DugiteError | null = null - const acceptableExitCode = opts.successExitCodes - ? opts.successExitCodes.has(exitCode) - : false - if (!acceptableExitCode) { - gitError = parseError(coerceToString(result.stderr)) - if (gitError === null) { - gitError = parseError(coerceToString(result.stdout)) - } - } - - const gitErrorDescription = - gitError !== null - ? getDescriptionForError(gitError, coerceToString(result.stderr)) - : null - const gitResult = { - ...result, - gitError, - gitErrorDescription, - path, - } - - let acceptableError = true - if (gitError !== null && opts.expectedErrors) { - acceptableError = opts.expectedErrors.has(gitError) - } - if ((gitError !== null && acceptableError) || acceptableExitCode) { - return gitResult - } - - // The caller should either handle this error, or expect that exit code. - const errorMessage = new Array() - errorMessage.push( - `\`git ${args.join(' ')}\` exited with an unexpected code: ${exitCode}.` - ) - - if (terminalOutput.length > 0) { - // Leave even less of the combined output in the log - errorMessage.push(terminalOutput.slice(-1024)) - } + if (terminalOutput.length > 0) { + // Leave even less of the combined output in the log + errorMessage.push(terminalOutput.slice(-1024)) + } - if (gitError !== null) { - errorMessage.push( - `(The error was parsed as ${gitError}: ${gitErrorDescription})` - ) - } + if (gitError !== null) { + errorMessage.push( + `(The error was parsed as ${gitError}: ${gitErrorDescription})` + ) + } - log.error(errorMessage.join('\n')) + log.error(errorMessage.join('\n')) - throw new GitError(gitResult, args, terminalOutput) - }, + throw new GitError(gitResult, args, terminalOutput) + }, + path, + options?.isBackgroundTask ?? false, + hooksEnv + ), path, - options?.isBackgroundTask ?? false, + options?.isBackgroundTask, options?.env ) } diff --git a/app/src/lib/hooks/get-repo-hooks.ts b/app/src/lib/hooks/get-repo-hooks.ts new file mode 100644 index 00000000000..da7f74685a8 --- /dev/null +++ b/app/src/lib/hooks/get-repo-hooks.ts @@ -0,0 +1,43 @@ +import { exec } from 'dugite' +import { access, constants, readdir } from 'fs/promises' +import { join, resolve } from 'path' + +const isExecutable = (path: string) => + access(path, constants.X_OK) + .then(() => true) + .catch(() => false) + +export async function* getRepoHooks(path: string) { + // TODO: Could we cache this? For just a little while? + // Probably not because we need to react to changes to core.hooksPath on the + // fly but it sure would be nice. + const { exitCode, stdout } = await exec( + ['config', '-z', '--get', 'core.hooksPath'], + path + ) + + const hooksPath = + exitCode === 0 + ? resolve(path, stdout.split('\0')[0]) + : join(path, '.git', 'hooks') + + const files = await readdir(hooksPath, { withFileTypes: true }) + .then(entries => entries.filter(x => x.isFile())) + .catch(() => []) + + for (const hook of files) { + const hookPath = join(hook.parentPath, hook.name) + + if (__WIN32__) { + if (hook.name.endsWith('.exe')) { + continue + } + } else { + if (!(await isExecutable(hookPath))) { + continue + } + } + + yield hookPath + } +} diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts new file mode 100644 index 00000000000..2433a769f3b --- /dev/null +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -0,0 +1,192 @@ +import { tmpdir } from 'os' +import { enableHooksEnvironment } from '../feature-flag' +import { getRepoHooks } from './get-repo-hooks' +import { cp, mkdtemp, rm } from 'fs/promises' +import { join, basename } from 'path' +import { createProxyProcessServer, ProcessProxyConnection } from 'process-proxy' +import { AddressInfo } from 'net' +import { spawn } from 'child_process' +import { Readable, Writable } from 'stream' + +const debug = (message: string, error?: Error) => { + log.debug(`hooks: ${message}`, error) +} + +const waitForWritableFinished = (stream: Writable) => { + return new Promise(resolve => { + if (stream.writableFinished) { + resolve() + } else { + stream.once('finish', () => resolve()) + } + }) +} + +const exitWithError = ( + connection: ProcessProxyConnection, + message: string, + exitCode = 1 +) => { + return new Promise((resolve, reject) => { + connection.stderr.end(`${message}\n`, () => { + connection.exit(exitCode).then(resolve, err => { + debug( + `failed to exit proxy: ${ + err instanceof Error ? err.message : String(err) + }` + ) + resolve() + }) + }) + }) +} + +export async function withHooksEnv( + fn: (env: Record | undefined) => Promise, + path: string, + isBackgroundTask = false, + customEnv?: Record +): Promise { + if (!enableHooksEnvironment()) { + return fn(customEnv) + } + + const repoHooks = await Array.fromAsync(getRepoHooks(path)) + + if (repoHooks.length === 0) { + return fn(customEnv) + } + + const ext = __WIN32__ ? '.exe' : '' + const processProxyPath = join(__dirname, `process-proxy${ext}`) + + const token = crypto.randomUUID() + const server = createProxyProcessServer( + async connection => { + const abortController = new AbortController() + const args = await connection.getArgs() + const env = await connection.getEnv() + const cwd = await connection.getCwd() + + const hookName = __WIN32__ + ? basename(args[0]).replace(/\.exe$/i, '') + : basename(args[0]) + + const excludedEnvVars = new Set([ + // Dugite sets this to point to a custom git config file which + // we don't want to leak into the hook's environment + 'GIT_SYSTEM_CONFIG', + // We set this to point to a custom hooks path which we don't want + // leaking into the hook's environment. Initially I thought we would have + // to sanitize this to strip out the custom config we set and leave any + // user-configured but since we're executing the hook in a separate + // shell with login it would just get re-initialized there anyway. + 'GIT_CONFIG_PARAMETERS', + ]) + + const safeEnv = Object.fromEntries( + Object.entries(env).filter( + ([k]) => k.startsWith('GIT_') && excludedEnvVars.has(k) + ) + ) + + const hooksExecutable = + repoHooks.find(hook => hook.endsWith(hookName)) ?? + (__WIN32__ + ? repoHooks.find(hook => hook.endsWith(`${hookName}.exe`)) + : undefined) + + if (!hooksExecutable) { + debug(`hook executable not found for ${hookName}`) + await exitWithError( + connection, + `Error: hook executable not found for ${hookName}` + ) + return + } + + // TODO!!! Escape args properly!!! + const cmd = `"${hooksExecutable}" ${args.join(' ')}` + const child = spawn(process.env.SHELL ?? '/bin/bash', ['-ilc', cmd], { + cwd, + env: safeEnv, + signal: abortController.signal, + }) + .on('spawn', () => { + const pipe = (from: Readable, to: Writable, name: string) => { + from.pipe(to).on('error', err => { + debug(`${name} pipe error:`, err) + abortController.abort() + }) + } + + pipe(connection.stdin, child.stdin, 'stdin') + pipe(child.stdout, connection.stdout, 'stdout') + pipe(child.stderr, connection.stderr, 'stderr') + + child.on('close', async (code, signal) => { + // Ensure all data is flushed to the copilot before exiting + // the connection + await Promise.all([ + waitForWritableFinished(connection.stdout), + waitForWritableFinished(connection.stderr), + ]) + + if (code !== 0) { + debug(`exiting proxy with code ${code}`) + } + await connection.exit(code ?? 0).catch(err => { + debug(`failed to exit proxy:`, err) + }) + }) + }) + .on('error', async err => { + debug(`child error:`, err) + await exitWithError( + connection, + `Error: command failed: ${err.message}` + ) + }) + }, + { + validateConnection: async receivedToken => receivedToken === token, + } + ) + const port = await new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', () => { + resolve((server.address() as AddressInfo).port) + }) + }) + const tmpHooksDir = await mkdtemp(join(tmpdir(), 'desktop-git-hooks-')) + try { + for (const hook of repoHooks) { + const cleanHooksName = __WIN32__ + ? basename(hook).replace(/\.exe$/i, '') + : basename(hook) + + await cp(processProxyPath, join(tmpHooksDir, `${cleanHooksName}${ext}`)) + } + + const existingGitEnvConfig = + customEnv?.['GIT_CONFIG_PARAMETERS'] ?? + process.env['GIT_CONFIG_PARAMETERS'] ?? + '' + + const gitEnvConfigPrefix = + existingGitEnvConfig.length > 0 ? `${existingGitEnvConfig} ` : '' + + return await fn({ + ...customEnv, + // TODO: Do we need to escape tmpHooksDir? Could it possibly include a single quote? + // probably not? + GIT_CONFIG_PARAMETERS: `${gitEnvConfigPrefix}'core.hooksPath=${tmpHooksDir}'`, + PROCESS_PROXY_PORT: `${port}`, + PROCESS_PROXY_TOKEN: token, + }) + } finally { + // Clean up the temporary directory + await rm(tmpHooksDir, { recursive: true, force: true }).catch(() => { + // Ignore errors + }) + } +} diff --git a/package.json b/package.json index 3231e638980..aea56a105c2 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "parallel-webpack": "^2.6.0", "parse-dds": "^1.2.1", "prettier": "^2.6.0", + "process-proxy": "^0.3.0", "rimraf": "^6.0.1", "sass": "^1.27.0", "sass-loader": "^16.0.0", diff --git a/script/build.ts b/script/build.ts index b51060b27d3..42af025260f 100755 --- a/script/build.ts +++ b/script/build.ts @@ -7,6 +7,7 @@ import * as os from 'os' import packager, { OfficialArch, OsxNotarizeOptions } from 'electron-packager' import frontMatter from 'front-matter' import { externals } from '../app/webpack.common' +import { getProxyCommandPath } from 'process-proxy' interface IChooseALicense { readonly title: string @@ -364,6 +365,15 @@ function copyDependencies() { appPathMain ) } + + console.log(' Copying process-proxy binary') + copySync( + getProxyCommandPath(), + path.resolve( + outRoot, + process.platform === 'win32' ? 'process-proxy.exe' : 'process-proxy' + ) + ) } function generateLicenseMetadata(outRoot: string) { diff --git a/tsconfig.json b/tsconfig.json index 2fbed057219..ea6e0287778 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ "strict": true, "outDir": "./out", "useUnknownInCatchVariables": false, - "lib": ["ES2023", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable", "ESNext.Array"], "types": ["node"] }, "include": ["app/**/*.ts", "app/**/*.tsx", "app/**/*.d.tsx"], diff --git a/yarn.lock b/yarn.lock index 02fc6d45d52..12c3efb7bca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5629,6 +5629,11 @@ pretty-error@^4.0.0: lodash "^4.17.20" renderkid "^3.0.0" +process-proxy@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/process-proxy/-/process-proxy-0.3.0.tgz#5bdb0cf430214868d395b3a9a5b74f12f519afbb" + integrity sha512-DNyLMjWYgf8K2zT54XQkTCcoyQfOcWihRRamofYdlV8vOxSik+KcXYwwogrT+DQsJRfEJWFx7/s8te2BsHfmQw== + progress@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" From e1fd5c19633087e8780e6cbe45f7f025e3b8b985 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:35:50 -0500 Subject: [PATCH 083/865] Release 3.5.4-beta2 --- app/package.json | 2 +- changelog.json | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/package.json b/app/package.json index f492873dab0..756314b9b3a 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.5.4-beta1", + "version": "3.5.4-beta2", "main": "./main.js", "repository": { "type": "git", diff --git a/changelog.json b/changelog.json index 0884d7d6f2f..26f266c824a 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,12 @@ { "releases": { + "3.5.4-beta2": [ + "[Improved] The contrast on the pull request check run button icons now meet minimum 3:1 contrast requirements - #21189", + "[Fixed] Check run status icons in the re-run checks dialog have a status tooltip that is accessible by screenreaders - #21191", + "[Fixed] Fix: Show whitespace hint on context menu in diff row - #20848. Thanks @zekariasasaminew!", + "[Fixed] Improve dialog dismissal and sign-in button state handling - #21144. Thanks @zekariasasaminew!", + "[Fixed] Fix \"Update Email\" button after login into a different account - #21176" + ], "3.5.4-beta1": [ "[Added] Display line change count in PR Preview Dialog - #21126. Thanks @iammola!", "[Added] Allow users to skip commit message override confirmation - #21025. Thanks @ilyassesalama!", From b9ffe29cdaafa37eaafcc17925dce60fb828869f Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:36:28 -0500 Subject: [PATCH 084/865] Update changelog.json --- changelog.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.json b/changelog.json index 26f266c824a..a7a21606ccc 100644 --- a/changelog.json +++ b/changelog.json @@ -1,11 +1,11 @@ { "releases": { "3.5.4-beta2": [ - "[Improved] The contrast on the pull request check run button icons now meet minimum 3:1 contrast requirements - #21189", "[Fixed] Check run status icons in the re-run checks dialog have a status tooltip that is accessible by screenreaders - #21191", "[Fixed] Fix: Show whitespace hint on context menu in diff row - #20848. Thanks @zekariasasaminew!", "[Fixed] Improve dialog dismissal and sign-in button state handling - #21144. Thanks @zekariasasaminew!", - "[Fixed] Fix \"Update Email\" button after login into a different account - #21176" + "[Fixed] Fix \"Update Email\" button after login into a different account - #21176", + "[Improved] The contrast on the pull request check run button icons now meet minimum 3:1 contrast requirements - #21189" ], "3.5.4-beta1": [ "[Added] Display line change count in PR Preview Dialog - #21126. Thanks @iammola!", From fe93874ae11d2affb3def3d1a5fa423dc7732013 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:39:43 -0500 Subject: [PATCH 085/865] Update changelog.json --- changelog.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.json b/changelog.json index a7a21606ccc..a01aa931ec4 100644 --- a/changelog.json +++ b/changelog.json @@ -2,8 +2,8 @@ "releases": { "3.5.4-beta2": [ "[Fixed] Check run status icons in the re-run checks dialog have a status tooltip that is accessible by screenreaders - #21191", - "[Fixed] Fix: Show whitespace hint on context menu in diff row - #20848. Thanks @zekariasasaminew!", - "[Fixed] Improve dialog dismissal and sign-in button state handling - #21144. Thanks @zekariasasaminew!", + "[Fixed] Whitespace hint popover appears when right-clicking diff lines while \"Hide whitespace changes\" is enabled - #20848. Thanks @zekariasasaminew!", + "[Fixed] The cancel button in the sign-in dialog is enabled - #21144. Thanks @zekariasasaminew!", "[Fixed] Fix \"Update Email\" button after login into a different account - #21176", "[Improved] The contrast on the pull request check run button icons now meet minimum 3:1 contrast requirements - #21189" ], From 4f2a00bcc0cf7502145e59ac4f9c1cabc9ee69cf Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:39:58 -0500 Subject: [PATCH 086/865] Update changelog.json --- changelog.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.json b/changelog.json index a01aa931ec4..736a8de2b44 100644 --- a/changelog.json +++ b/changelog.json @@ -2,7 +2,7 @@ "releases": { "3.5.4-beta2": [ "[Fixed] Check run status icons in the re-run checks dialog have a status tooltip that is accessible by screenreaders - #21191", - "[Fixed] Whitespace hint popover appears when right-clicking diff lines while \"Hide whitespace changes\" is enabled - #20848. Thanks @zekariasasaminew!", + "[Fixed] The Whitespace hint popover appears when right-clicking diff lines while \"Hide whitespace changes\" is enabled - #20848. Thanks @zekariasasaminew!", "[Fixed] The cancel button in the sign-in dialog is enabled - #21144. Thanks @zekariasasaminew!", "[Fixed] Fix \"Update Email\" button after login into a different account - #21176", "[Improved] The contrast on the pull request check run button icons now meet minimum 3:1 contrast requirements - #21189" From 9fb8ab6df930f5a2e416fe4aa39e0b3d0c9fa147 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:42:05 -0500 Subject: [PATCH 087/865] Update changelog.json --- changelog.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.json b/changelog.json index 736a8de2b44..8a7d0dc276b 100644 --- a/changelog.json +++ b/changelog.json @@ -3,8 +3,8 @@ "3.5.4-beta2": [ "[Fixed] Check run status icons in the re-run checks dialog have a status tooltip that is accessible by screenreaders - #21191", "[Fixed] The Whitespace hint popover appears when right-clicking diff lines while \"Hide whitespace changes\" is enabled - #20848. Thanks @zekariasasaminew!", - "[Fixed] The cancel button in the sign-in dialog is enabled - #21144. Thanks @zekariasasaminew!", - "[Fixed] Fix \"Update Email\" button after login into a different account - #21176", + "[Fixed] The cancel button in the sign-in dialog is enabled after sign-in attempt - #21144. Thanks @zekariasasaminew!", + "[Fixed] The \"Update Email\" button in the \"Misattributed Commit\" popover works after login from a different account - #21176", "[Improved] The contrast on the pull request check run button icons now meet minimum 3:1 contrast requirements - #21189" ], "3.5.4-beta1": [ From 5919433f172759426983653183262e18ddf01f5f Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:42:18 -0500 Subject: [PATCH 088/865] Update changelog.json --- changelog.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.json b/changelog.json index 8a7d0dc276b..11ef2957b52 100644 --- a/changelog.json +++ b/changelog.json @@ -5,7 +5,7 @@ "[Fixed] The Whitespace hint popover appears when right-clicking diff lines while \"Hide whitespace changes\" is enabled - #20848. Thanks @zekariasasaminew!", "[Fixed] The cancel button in the sign-in dialog is enabled after sign-in attempt - #21144. Thanks @zekariasasaminew!", "[Fixed] The \"Update Email\" button in the \"Misattributed Commit\" popover works after login from a different account - #21176", - "[Improved] The contrast on the pull request check run button icons now meet minimum 3:1 contrast requirements - #21189" + "[Improved] The contrast on the pull request check run button icons meets minimum 3:1 contrast requirements - #21189" ], "3.5.4-beta1": [ "[Added] Display line change count in PR Preview Dialog - #21126. Thanks @iammola!", From 9f4e9c9d41f639bd1baf49c38f3533bf5685d0b5 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:43:16 -0500 Subject: [PATCH 089/865] Update changelog.json --- changelog.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.json b/changelog.json index 11ef2957b52..480c5acb2bf 100644 --- a/changelog.json +++ b/changelog.json @@ -5,7 +5,7 @@ "[Fixed] The Whitespace hint popover appears when right-clicking diff lines while \"Hide whitespace changes\" is enabled - #20848. Thanks @zekariasasaminew!", "[Fixed] The cancel button in the sign-in dialog is enabled after sign-in attempt - #21144. Thanks @zekariasasaminew!", "[Fixed] The \"Update Email\" button in the \"Misattributed Commit\" popover works after login from a different account - #21176", - "[Improved] The contrast on the pull request check run button icons meets minimum 3:1 contrast requirements - #21189" + "[Improved] The icon contrast on the pull request check run button meets minimum 3:1 contrast requirements - #21189" ], "3.5.4-beta1": [ "[Added] Display line change count in PR Preview Dialog - #21126. Thanks @iammola!", From 2f776bab0956960906f2ecf40f22b3a2d61f5950 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 3 Nov 2025 16:41:13 +0100 Subject: [PATCH 090/865] Safety++ --- app/package.json | 1 + app/src/lib/hooks/with-hooks-env.ts | 58 ++++++++++++++++++++++++----- app/yarn.lock | 19 ++++++++++ 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/app/package.json b/app/package.json index f492873dab0..6902684b531 100644 --- a/app/package.json +++ b/app/package.json @@ -59,6 +59,7 @@ "react-transition-group": "^4.4.1", "react-virtualized": "^9.20.0", "registry-js": "^1.16.0", + "shescape": "^2.1.6", "source-map-support": "^0.4.15", "split2": "^4.2.0", "string-argv": "^0.3.2", diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index 2433a769f3b..d24a20e97c8 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -7,11 +7,46 @@ import { createProxyProcessServer, ProcessProxyConnection } from 'process-proxy' import { AddressInfo } from 'net' import { spawn } from 'child_process' import { Readable, Writable } from 'stream' +import { Shescape } from 'shescape' +import memoizeOne from 'memoize-one' const debug = (message: string, error?: Error) => { log.debug(`hooks: ${message}`, error) } +const getShell = () => { + // TODO: Windows: + if (__WIN32__) { + throw new Error('Not implemented') + } + + if (process.env.SHELL) { + try { + return { + shell: process.env.SHELL, + args: ['-ilc'], + ...getQuoteFn(process.env.SHELL), + } + } catch (err) { + debug('Failed resolving shell', err) + } + } + + return { + shell: '/bin/sh', + args: ['-ilc'], + ...getQuoteFn('/bin/sh'), + } +} + +const getQuoteFn = memoizeOne((shell: string) => { + const shescape = new Shescape({ shell, flagProtection: false }) + return { + escape: shescape.escape.bind(shescape), + quote: shescape.quote.bind(shescape), + } +}) + const waitForWritableFinished = (stream: Writable) => { return new Promise(resolve => { if (stream.writableFinished) { @@ -64,13 +99,13 @@ export async function withHooksEnv( const server = createProxyProcessServer( async connection => { const abortController = new AbortController() - const args = await connection.getArgs() - const env = await connection.getEnv() - const cwd = await connection.getCwd() + const proxyArgs = await connection.getArgs() + const proxyEnv = await connection.getEnv() + const proxyCwd = await connection.getCwd() const hookName = __WIN32__ - ? basename(args[0]).replace(/\.exe$/i, '') - : basename(args[0]) + ? basename(proxyArgs[0]).replace(/\.exe$/i, '') + : basename(proxyArgs[0]) const excludedEnvVars = new Set([ // Dugite sets this to point to a custom git config file which @@ -85,7 +120,7 @@ export async function withHooksEnv( ]) const safeEnv = Object.fromEntries( - Object.entries(env).filter( + Object.entries(proxyEnv).filter( ([k]) => k.startsWith('GIT_') && excludedEnvVars.has(k) ) ) @@ -105,10 +140,13 @@ export async function withHooksEnv( return } - // TODO!!! Escape args properly!!! - const cmd = `"${hooksExecutable}" ${args.join(' ')}` - const child = spawn(process.env.SHELL ?? '/bin/bash', ['-ilc', cmd], { - cwd, + const { shell, args: shellArgs, quote } = getShell() + + const cmdArgs = [hooksExecutable, ...proxyArgs.slice(1)] + const cmd = cmdArgs.map(quote).join(' ') + + const child = spawn(shell, [...shellArgs, cmd], { + cwd: proxyCwd, env: safeEnv, signal: abortController.signal, }) diff --git a/app/yarn.lock b/app/yarn.lock index e8ceb3d72d4..9745139aaac 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -597,6 +597,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +isexe@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d" + integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ== + js-tokens@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" @@ -1196,6 +1201,13 @@ shebang-regex@^1.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= +shescape@^2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/shescape/-/shescape-2.1.6.tgz#6185c81049e7e2d510d5b33cabf820b1eaa0b743" + integrity sha512-c9Ns1I+Tl0TC+cpsOT1FeZcvFalfd0WfHeD/CMccJH20xwochmJzq6AqtenndlyAw/BUi3BMcv92dYLVrqX+dw== + dependencies: + which "^3.0.0 || ^4.0.0 || ^5.0.0" + signal-exit@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" @@ -1466,6 +1478,13 @@ which@^1.2.9: dependencies: isexe "^2.0.0" +"which@^3.0.0 || ^4.0.0 || ^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/which/-/which-5.0.0.tgz#d93f2d93f79834d4363c7d0c23e00d07c466c8d6" + integrity sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ== + dependencies: + isexe "^3.1.1" + wide-align@^1.1.0: version "1.1.3" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" From 1b5d6e44386254d10e423987c9cdb50452ecda1e Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 3 Nov 2025 17:11:13 +0100 Subject: [PATCH 091/865] What even is that comment --- app/src/lib/hooks/with-hooks-env.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index d24a20e97c8..7947661d347 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -163,8 +163,6 @@ export async function withHooksEnv( pipe(child.stderr, connection.stderr, 'stderr') child.on('close', async (code, signal) => { - // Ensure all data is flushed to the copilot before exiting - // the connection await Promise.all([ waitForWritableFinished(connection.stdout), waitForWritableFinished(connection.stderr), From 8e8e08266745cffb9a24c3373901035eb691b15c Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 3 Nov 2025 17:25:22 +0100 Subject: [PATCH 092/865] Only intercept hooks when requested --- app/src/lib/git/commit.ts | 1 + app/src/lib/git/core.ts | 5 +- app/src/lib/hooks/hooks-proxy.ts | 159 ++++++++++++++++++++++++ app/src/lib/hooks/with-hooks-env.ts | 181 ++-------------------------- 4 files changed, 174 insertions(+), 172 deletions(-) create mode 100644 app/src/lib/hooks/hooks-proxy.ts diff --git a/app/src/lib/git/commit.ts b/app/src/lib/git/commit.ts index a54b701f87d..44a49b2ce48 100644 --- a/app/src/lib/git/commit.ts +++ b/app/src/lib/git/commit.ts @@ -37,6 +37,7 @@ export async function createCommit( 'createCommit', { stdin: message, + interceptHooks: true, } ) return parseCommitSHA(result) diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 5eec52a182c..4b581df300f 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -64,6 +64,8 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { * This affects error handling and UI such as credential prompts. */ readonly isBackgroundTask?: boolean + + readonly interceptHooks?: boolean } /** @@ -346,8 +348,7 @@ export async function git( hooksEnv ), path, - options?.isBackgroundTask, - options?.env + options ) } diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts new file mode 100644 index 00000000000..bec70fec32c --- /dev/null +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -0,0 +1,159 @@ +import { spawn } from 'child_process' +import memoizeOne from 'memoize-one' +import { basename } from 'path' +import { ProcessProxyConnection } from 'process-proxy' +import { Shescape } from 'shescape' +import { Readable, Writable } from 'stream' + +const debug = (message: string, error?: Error) => { + log.debug(`hooks: ${message}`, error) +} + +const getShell = () => { + // TODO: Windows: + if (__WIN32__) { + throw new Error('Not implemented') + } + + if (process.env.SHELL) { + try { + return { + shell: process.env.SHELL, + args: ['-ilc'], + ...getQuoteFn(process.env.SHELL), + } + } catch (err) { + debug('Failed resolving shell', err) + } + } + + return { + shell: '/bin/sh', + args: ['-ilc'], + ...getQuoteFn('/bin/sh'), + } +} + +const getQuoteFn = memoizeOne((shell: string) => { + const shescape = new Shescape({ shell, flagProtection: false }) + return { + escape: shescape.escape.bind(shescape), + quote: shescape.quote.bind(shescape), + } +}) + +const waitForWritableFinished = (stream: Writable) => { + return new Promise(resolve => { + if (stream.writableFinished) { + resolve() + } else { + stream.once('finish', () => resolve()) + } + }) +} + +const exitWithError = ( + connection: ProcessProxyConnection, + message: string, + exitCode = 1 +) => { + return new Promise((resolve, reject) => { + connection.stderr.end(`${message}\n`, () => { + connection.exit(exitCode).then(resolve, err => { + debug( + `failed to exit proxy: ${ + err instanceof Error ? err.message : String(err) + }` + ) + resolve() + }) + }) + }) +} + +export const createHooksProxy = (repoHooks: string[]) => { + return async (connection: ProcessProxyConnection) => { + const abortController = new AbortController() + const proxyArgs = await connection.getArgs() + const proxyEnv = await connection.getEnv() + const proxyCwd = await connection.getCwd() + + const hookName = __WIN32__ + ? basename(proxyArgs[0]).replace(/\.exe$/i, '') + : basename(proxyArgs[0]) + + const excludedEnvVars = new Set([ + // Dugite sets this to point to a custom git config file which + // we don't want to leak into the hook's environment + 'GIT_SYSTEM_CONFIG', + // We set this to point to a custom hooks path which we don't want + // leaking into the hook's environment. Initially I thought we would have + // to sanitize this to strip out the custom config we set and leave any + // user-configured but since we're executing the hook in a separate + // shell with login it would just get re-initialized there anyway. + 'GIT_CONFIG_PARAMETERS', + ]) + + const safeEnv = Object.fromEntries( + Object.entries(proxyEnv).filter( + ([k]) => k.startsWith('GIT_') && excludedEnvVars.has(k) + ) + ) + + const hooksExecutable = + repoHooks.find(hook => hook.endsWith(hookName)) ?? + (__WIN32__ + ? repoHooks.find(hook => hook.endsWith(`${hookName}.exe`)) + : undefined) + + if (!hooksExecutable) { + debug(`hook executable not found for ${hookName}`) + await exitWithError( + connection, + `Error: hook executable not found for ${hookName}` + ) + return + } + + const { shell, args: shellArgs, quote } = getShell() + + const cmdArgs = [hooksExecutable, ...proxyArgs.slice(1)] + const cmd = cmdArgs.map(quote).join(' ') + + const child = spawn(shell, [...shellArgs, cmd], { + cwd: proxyCwd, + env: safeEnv, + signal: abortController.signal, + }) + .on('spawn', () => { + const pipe = (from: Readable, to: Writable, name: string) => { + from.pipe(to).on('error', err => { + debug(`${name} pipe error:`, err) + abortController.abort() + }) + } + + pipe(connection.stdin, child.stdin, 'stdin') + pipe(child.stdout, connection.stdout, 'stdout') + pipe(child.stderr, connection.stderr, 'stderr') + + child.on('close', async (code, signal) => { + await Promise.all([ + waitForWritableFinished(connection.stdout), + waitForWritableFinished(connection.stderr), + ]) + + if (code !== 0) { + debug(`exiting proxy with code ${code}`) + } + await connection.exit(code ?? 0).catch(err => { + debug(`failed to exit proxy:`, err) + }) + }) + }) + .on('error', async err => { + debug(`child error:`, err) + await exitWithError(connection, `Error: command failed: ${err.message}`) + }) + } +} diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index 7947661d347..66b6bd28006 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -3,191 +3,33 @@ import { enableHooksEnvironment } from '../feature-flag' import { getRepoHooks } from './get-repo-hooks' import { cp, mkdtemp, rm } from 'fs/promises' import { join, basename } from 'path' -import { createProxyProcessServer, ProcessProxyConnection } from 'process-proxy' +import { createProxyProcessServer } from 'process-proxy' import { AddressInfo } from 'net' -import { spawn } from 'child_process' -import { Readable, Writable } from 'stream' -import { Shescape } from 'shescape' -import memoizeOne from 'memoize-one' - -const debug = (message: string, error?: Error) => { - log.debug(`hooks: ${message}`, error) -} - -const getShell = () => { - // TODO: Windows: - if (__WIN32__) { - throw new Error('Not implemented') - } - - if (process.env.SHELL) { - try { - return { - shell: process.env.SHELL, - args: ['-ilc'], - ...getQuoteFn(process.env.SHELL), - } - } catch (err) { - debug('Failed resolving shell', err) - } - } - - return { - shell: '/bin/sh', - args: ['-ilc'], - ...getQuoteFn('/bin/sh'), - } -} - -const getQuoteFn = memoizeOne((shell: string) => { - const shescape = new Shescape({ shell, flagProtection: false }) - return { - escape: shescape.escape.bind(shescape), - quote: shescape.quote.bind(shescape), - } -}) - -const waitForWritableFinished = (stream: Writable) => { - return new Promise(resolve => { - if (stream.writableFinished) { - resolve() - } else { - stream.once('finish', () => resolve()) - } - }) -} - -const exitWithError = ( - connection: ProcessProxyConnection, - message: string, - exitCode = 1 -) => { - return new Promise((resolve, reject) => { - connection.stderr.end(`${message}\n`, () => { - connection.exit(exitCode).then(resolve, err => { - debug( - `failed to exit proxy: ${ - err instanceof Error ? err.message : String(err) - }` - ) - resolve() - }) - }) - }) -} +import type { IGitExecutionOptions } from '../git/core' +import { createHooksProxy } from './hooks-proxy' export async function withHooksEnv( fn: (env: Record | undefined) => Promise, path: string, - isBackgroundTask = false, - customEnv?: Record + options: IGitExecutionOptions | undefined ): Promise { - if (!enableHooksEnvironment()) { - return fn(customEnv) + if (options?.interceptHooks !== true || !enableHooksEnvironment()) { + return fn(options?.env) } const repoHooks = await Array.fromAsync(getRepoHooks(path)) if (repoHooks.length === 0) { - return fn(customEnv) + return fn(options?.env) } const ext = __WIN32__ ? '.exe' : '' const processProxyPath = join(__dirname, `process-proxy${ext}`) const token = crypto.randomUUID() - const server = createProxyProcessServer( - async connection => { - const abortController = new AbortController() - const proxyArgs = await connection.getArgs() - const proxyEnv = await connection.getEnv() - const proxyCwd = await connection.getCwd() - - const hookName = __WIN32__ - ? basename(proxyArgs[0]).replace(/\.exe$/i, '') - : basename(proxyArgs[0]) - - const excludedEnvVars = new Set([ - // Dugite sets this to point to a custom git config file which - // we don't want to leak into the hook's environment - 'GIT_SYSTEM_CONFIG', - // We set this to point to a custom hooks path which we don't want - // leaking into the hook's environment. Initially I thought we would have - // to sanitize this to strip out the custom config we set and leave any - // user-configured but since we're executing the hook in a separate - // shell with login it would just get re-initialized there anyway. - 'GIT_CONFIG_PARAMETERS', - ]) - - const safeEnv = Object.fromEntries( - Object.entries(proxyEnv).filter( - ([k]) => k.startsWith('GIT_') && excludedEnvVars.has(k) - ) - ) - - const hooksExecutable = - repoHooks.find(hook => hook.endsWith(hookName)) ?? - (__WIN32__ - ? repoHooks.find(hook => hook.endsWith(`${hookName}.exe`)) - : undefined) - - if (!hooksExecutable) { - debug(`hook executable not found for ${hookName}`) - await exitWithError( - connection, - `Error: hook executable not found for ${hookName}` - ) - return - } - - const { shell, args: shellArgs, quote } = getShell() - - const cmdArgs = [hooksExecutable, ...proxyArgs.slice(1)] - const cmd = cmdArgs.map(quote).join(' ') - - const child = spawn(shell, [...shellArgs, cmd], { - cwd: proxyCwd, - env: safeEnv, - signal: abortController.signal, - }) - .on('spawn', () => { - const pipe = (from: Readable, to: Writable, name: string) => { - from.pipe(to).on('error', err => { - debug(`${name} pipe error:`, err) - abortController.abort() - }) - } - - pipe(connection.stdin, child.stdin, 'stdin') - pipe(child.stdout, connection.stdout, 'stdout') - pipe(child.stderr, connection.stderr, 'stderr') - - child.on('close', async (code, signal) => { - await Promise.all([ - waitForWritableFinished(connection.stdout), - waitForWritableFinished(connection.stderr), - ]) - - if (code !== 0) { - debug(`exiting proxy with code ${code}`) - } - await connection.exit(code ?? 0).catch(err => { - debug(`failed to exit proxy:`, err) - }) - }) - }) - .on('error', async err => { - debug(`child error:`, err) - await exitWithError( - connection, - `Error: command failed: ${err.message}` - ) - }) - }, - { - validateConnection: async receivedToken => receivedToken === token, - } - ) + const server = createProxyProcessServer(createHooksProxy(repoHooks), { + validateConnection: async receivedToken => receivedToken === token, + }) const port = await new Promise((resolve, reject) => { server.listen(0, '127.0.0.1', () => { resolve((server.address() as AddressInfo).port) @@ -204,7 +46,7 @@ export async function withHooksEnv( } const existingGitEnvConfig = - customEnv?.['GIT_CONFIG_PARAMETERS'] ?? + options?.env?.['GIT_CONFIG_PARAMETERS'] ?? process.env['GIT_CONFIG_PARAMETERS'] ?? '' @@ -212,7 +54,6 @@ export async function withHooksEnv( existingGitEnvConfig.length > 0 ? `${existingGitEnvConfig} ` : '' return await fn({ - ...customEnv, // TODO: Do we need to escape tmpHooksDir? Could it possibly include a single quote? // probably not? GIT_CONFIG_PARAMETERS: `${gitEnvConfigPrefix}'core.hooksPath=${tmpHooksDir}'`, From 92cc2dd17d6f5454cc455eeb59a55701825f8883 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 3 Nov 2025 17:40:00 +0100 Subject: [PATCH 093/865] Filter hooks --- app/src/lib/git/commit.ts | 9 ++++++++- app/src/lib/git/core.ts | 2 +- app/src/lib/hooks/get-repo-hooks.ts | 6 +++++- app/src/lib/hooks/with-hooks-env.ts | 11 +++++++++-- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/src/lib/git/commit.ts b/app/src/lib/git/commit.ts index 44a49b2ce48..d84be370f2b 100644 --- a/app/src/lib/git/commit.ts +++ b/app/src/lib/git/commit.ts @@ -37,7 +37,14 @@ export async function createCommit( 'createCommit', { stdin: message, - interceptHooks: true, + // https://git-scm.com/docs/githooks/2.46.1 + interceptHooks: [ + 'pre-commit', + 'prepare-commit-msg', + 'commit-msg', + 'post-commit', + 'post-rewrite', + ], } ) return parseCommitSHA(result) diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 4b581df300f..423b523a5e7 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -65,7 +65,7 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { */ readonly isBackgroundTask?: boolean - readonly interceptHooks?: boolean + readonly interceptHooks?: boolean | string[] } /** diff --git a/app/src/lib/hooks/get-repo-hooks.ts b/app/src/lib/hooks/get-repo-hooks.ts index da7f74685a8..f28dac0cb36 100644 --- a/app/src/lib/hooks/get-repo-hooks.ts +++ b/app/src/lib/hooks/get-repo-hooks.ts @@ -7,7 +7,7 @@ const isExecutable = (path: string) => .then(() => true) .catch(() => false) -export async function* getRepoHooks(path: string) { +export async function* getRepoHooks(path: string, filter?: string[]) { // TODO: Could we cache this? For just a little while? // Probably not because we need to react to changes to core.hooksPath on the // fly but it sure would be nice. @@ -26,6 +26,10 @@ export async function* getRepoHooks(path: string) { .catch(() => []) for (const hook of files) { + if (filter && !filter.includes(hook.name)) { + continue + } + const hookPath = join(hook.parentPath, hook.name) if (__WIN32__) { diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index 66b6bd28006..8911eaf7152 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -13,11 +13,18 @@ export async function withHooksEnv( path: string, options: IGitExecutionOptions | undefined ): Promise { - if (options?.interceptHooks !== true || !enableHooksEnvironment()) { + const interceptHooks = options?.interceptHooks ?? false + + if (!interceptHooks || !enableHooksEnvironment()) { return fn(options?.env) } - const repoHooks = await Array.fromAsync(getRepoHooks(path)) + const repoHooks = await Array.fromAsync( + getRepoHooks( + path, + typeof interceptHooks === 'object' ? interceptHooks : undefined + ) + ) if (repoHooks.length === 0) { return fn(options?.env) From 6069a85ed1ad33c66a3ff89177c8eb711b192b80 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:10:04 -0500 Subject: [PATCH 094/865] Update ci.yml --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43e3ddb75fe..e759efb8a36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,6 @@ jobs: cache: yarn - run: yarn - run: yarn validate-electron-version - - run: yarn validate-macos-version - run: yarn lint - run: yarn validate-changelog - name: Ensure a clean working directory @@ -101,6 +100,9 @@ jobs: env: npm_config_arch: ${{ matrix.arch }} TARGET_ARCH: ${{ matrix.arch }} + - name: Validate macOS version + if: runner.os == 'macOS' + run: yarn validate-macos-version - name: Run desktop-trampoline tests run: | cd vendor/desktop-trampoline From 309dc5a16cf9e7c51e78fe3005382cedaa6f4c9c Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 09:34:55 +0100 Subject: [PATCH 095/865] Update dugite to version 3.0.0 Bumped dugite from 3.0.0-rc12 to the stable 3.0.0 release in package.json and yarn.lock for improved stability and compatibility. --- app/package.json | 2 +- app/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/package.json b/app/package.json index 756314b9b3a..faa175bed32 100644 --- a/app/package.json +++ b/app/package.json @@ -33,7 +33,7 @@ "desktop-trampoline": "desktop/desktop-trampoline#v0.9.10", "dexie": "^3.2.3", "dompurify": "^3.2.4", - "dugite": "3.0.0-rc12", + "dugite": "^3.0.0", "electron-window-state": "^5.0.3", "event-kit": "^2.0.0", "focus-trap-react": "^8.1.0", diff --git a/app/yarn.lock b/app/yarn.lock index e8ceb3d72d4..8377665b989 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -377,10 +377,10 @@ dompurify@^3.2.4: optionalDependencies: "@types/trusted-types" "^2.0.7" -dugite@3.0.0-rc12: - version "3.0.0-rc12" - resolved "https://registry.yarnpkg.com/dugite/-/dugite-3.0.0-rc12.tgz#e4ea9b34f3d542c4d66796e68f309b31edc5d03a" - integrity sha512-U3nlYkfcC7y8PM+87TGWCEzyNoaHkZoPXQodZYHF0SmFM4RrcnKNaxIcN5Gi6RwOvFe6hejJXvMc8oYIJRp7hw== +dugite@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/dugite/-/dugite-3.0.0.tgz#56621ad397579d9a58c10c6a4f5e646b35937046" + integrity sha512-+q2i3y5TvlC2YaZofkdELHtmvHbT6yuBODimItxU6xEGtHqRt6rpApJzf6lAqtpo+y1gokhfsHyULH0yNZuTWQ== dependencies: progress "^2.0.3" tar-stream "^3.1.7" From 11188ddc6d6f7a2b805d903a3c8d7912aca88c47 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 13:05:26 +0100 Subject: [PATCH 096/865] Refactor hooks proxy to support stdin file handling Updated hooks-proxy to write stdin to a temporary file for hooks that require it, and refactored process spawning logic for improved error handling and resource management. The with-hooks-env module now creates a temporary directory for hook execution and passes it to the proxy. --- app/src/lib/hooks/hooks-proxy.ts | 108 +++++++++++++++++----------- app/src/lib/hooks/with-hooks-env.ts | 11 +-- 2 files changed, 75 insertions(+), 44 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index bec70fec32c..479d6d581b0 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -1,9 +1,14 @@ import { spawn } from 'child_process' +import { randomBytes } from 'crypto' +import { createWriteStream } from 'fs' import memoizeOne from 'memoize-one' -import { basename } from 'path' +import { basename, join } from 'path' import { ProcessProxyConnection } from 'process-proxy' import { Shescape } from 'shescape' -import { Readable, Writable } from 'stream' +import { Writable } from 'stream' +import { pipeline } from 'stream/promises' + +const hooksUsingStdin = ['post-rewrite'] const debug = (message: string, error?: Error) => { log.debug(`hooks: ${message}`, error) @@ -71,9 +76,8 @@ const exitWithError = ( }) } -export const createHooksProxy = (repoHooks: string[]) => { +export const createHooksProxy = (repoHooks: string[], tmpDir: string) => { return async (connection: ProcessProxyConnection) => { - const abortController = new AbortController() const proxyArgs = await connection.getArgs() const proxyEnv = await connection.getEnv() const proxyCwd = await connection.getCwd() @@ -96,7 +100,7 @@ export const createHooksProxy = (repoHooks: string[]) => { const safeEnv = Object.fromEntries( Object.entries(proxyEnv).filter( - ([k]) => k.startsWith('GIT_') && excludedEnvVars.has(k) + ([k]) => k.startsWith('GIT_') && !excludedEnvVars.has(k) ) ) @@ -115,45 +119,69 @@ export const createHooksProxy = (repoHooks: string[]) => { return } + // We don't have to clean this up since it's in the tmpdir created by the + // hooks env. + const stdinFilePath = join( + tmpDir, + `${hookName}-stdin-${randomBytes(8).toString('hex')}` + ) + + const hasStdin = hooksUsingStdin.includes(hookName) + + if (hasStdin) { + await pipeline( + connection.stdin, + createWriteStream(stdinFilePath, { mode: 0o600 }) + ) + } + const { shell, args: shellArgs, quote } = getShell() - const cmdArgs = [hooksExecutable, ...proxyArgs.slice(1)] + const cmdArgs = [ + 'git', + 'hook', + 'run', + hookName, + ...(hasStdin ? ['--to-stdin', stdinFilePath] : []), + '--', + ...proxyArgs.slice(1), + ] const cmd = cmdArgs.map(quote).join(' ') - const child = spawn(shell, [...shellArgs, cmd], { - cwd: proxyCwd, - env: safeEnv, - signal: abortController.signal, - }) - .on('spawn', () => { - const pipe = (from: Readable, to: Writable, name: string) => { - from.pipe(to).on('error', err => { - debug(`${name} pipe error:`, err) - abortController.abort() - }) - } - - pipe(connection.stdin, child.stdin, 'stdin') - pipe(child.stdout, connection.stdout, 'stdout') - pipe(child.stderr, connection.stderr, 'stderr') - - child.on('close', async (code, signal) => { - await Promise.all([ - waitForWritableFinished(connection.stdout), - waitForWritableFinished(connection.stderr), - ]) - - if (code !== 0) { - debug(`exiting proxy with code ${code}`) - } - await connection.exit(code ?? 0).catch(err => { - debug(`failed to exit proxy:`, err) - }) - }) - }) - .on('error', async err => { - debug(`child error:`, err) - await exitWithError(connection, `Error: command failed: ${err.message}`) + const { code } = await new Promise<{ + code: number | null + signal: NodeJS.Signals | null + }>((resolve, reject) => { + const abortController = new AbortController() + connection.on('close', () => abortController.abort()) + + const child = spawn(shell, [...shellArgs, cmd], { + cwd: proxyCwd, + env: safeEnv, + signal: abortController.signal, }) + .on('spawn', () => { + // TODO: Do hooks ever write to stdout? Probably not? + // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 + child.stdout.pipe(connection.stdout).on('error', reject) + child.stderr.pipe(connection.stderr).on('error', reject) + child.on('close', (code, signal) => resolve({ code, signal })) + }) + .on('error', reject) + }) + + await Promise.all([ + waitForWritableFinished(connection.stdout), + waitForWritableFinished(connection.stderr), + ]).catch(e => { + debug(`waiting for writable to finish failed`, e) + }) + + if (code !== 0) { + debug(`exiting proxy with code ${code}`) + } + await connection + .exit(code ?? 0) + .catch(err => debug(`failed to exit proxy:`, err)) } } diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index 8911eaf7152..efd494c68d5 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -34,15 +34,18 @@ export async function withHooksEnv( const processProxyPath = join(__dirname, `process-proxy${ext}`) const token = crypto.randomUUID() - const server = createProxyProcessServer(createHooksProxy(repoHooks), { - validateConnection: async receivedToken => receivedToken === token, - }) + const tmpHooksDir = await mkdtemp(join(tmpdir(), 'desktop-git-hooks-')) + const hooksProxy = createHooksProxy(repoHooks, tmpHooksDir) + + const server = createProxyProcessServer( + conn => hooksProxy(conn).catch(e => conn.exit(1).catch(() => {})), + { validateConnection: async receivedToken => receivedToken === token } + ) const port = await new Promise((resolve, reject) => { server.listen(0, '127.0.0.1', () => { resolve((server.address() as AddressInfo).port) }) }) - const tmpHooksDir = await mkdtemp(join(tmpdir(), 'desktop-git-hooks-')) try { for (const hook of repoHooks) { const cleanHooksName = __WIN32__ From 14ee6bc7258f4fcd373ace4195e5fc8cb29d20ba Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 14:10:23 +0100 Subject: [PATCH 097/865] Refactor hook proxy output handling and add timing Simplifies hook process output handling by only piping stderr, as Git hooks only write to stderr. Adds execution timing and improved debug logging for hook completion, including exit code and duration. --- app/src/lib/hooks/hooks-proxy.ts | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 479d6d581b0..d46a42402db 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -78,6 +78,7 @@ const exitWithError = ( export const createHooksProxy = (repoHooks: string[], tmpDir: string) => { return async (connection: ProcessProxyConnection) => { + const startTime = Date.now() const proxyArgs = await connection.getArgs() const proxyEnv = await connection.getEnv() const proxyCwd = await connection.getCwd() @@ -160,26 +161,22 @@ export const createHooksProxy = (repoHooks: string[], tmpDir: string) => { env: safeEnv, signal: abortController.signal, }) - .on('spawn', () => { - // TODO: Do hooks ever write to stdout? Probably not? - // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 - child.stdout.pipe(connection.stdout).on('error', reject) - child.stderr.pipe(connection.stderr).on('error', reject) - child.on('close', (code, signal) => resolve({ code, signal })) - }) .on('error', reject) + .on('close', (code, signal) => resolve({ code, signal })) + + // Git hooks only write to stderr + // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 + child.stderr.pipe(connection.stderr).on('error', reject) }) - await Promise.all([ - waitForWritableFinished(connection.stdout), - waitForWritableFinished(connection.stderr), - ]).catch(e => { - debug(`waiting for writable to finish failed`, e) + await waitForWritableFinished(connection.stderr).catch(e => { + debug(`waiting for stderr to finish failed`, e) }) - if (code !== 0) { - debug(`exiting proxy with code ${code}`) - } + const elapsedSeconds = (Date.now() - startTime) / 1000 + debug( + `executed ${hookName}: exited with code ${code} in ${elapsedSeconds}s` + ) await connection .exit(code ?? 0) .catch(err => debug(`failed to exit proxy:`, err)) From 715281a3f0fc1870bf43feb0dc098d4db1a4ddb0 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 14:14:15 +0100 Subject: [PATCH 098/865] Bump version and changelog --- app/package.json | 2 +- changelog.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/package.json b/app/package.json index faa175bed32..2c6243edf89 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.5.4-beta2", + "version": "3.5.4-beta3", "main": "./main.js", "repository": { "type": "git", diff --git a/changelog.json b/changelog.json index 480c5acb2bf..4987c31734e 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,9 @@ { "releases": { + "3.5.4-beta3": [ + "[Fixed] Updated Git LFS to 3.7.1 to address CVE-2025-26625 - #21223", + "[Fixed] Fix: menu bar flickering when profile modal is open by adding capture phase click listener - #21150. Thanks @zekariasasaminew!" + ], "3.5.4-beta2": [ "[Fixed] Check run status icons in the re-run checks dialog have a status tooltip that is accessible by screenreaders - #21191", "[Fixed] The Whitespace hint popover appears when right-clicking diff lines while \"Hide whitespace changes\" is enabled - #20848. Thanks @zekariasasaminew!", From 9dfb8d8dc1039ecbde24bc01e2502a814b94687c Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 14:16:54 +0100 Subject: [PATCH 099/865] Bump version and add changelog --- app/package.json | 2 +- changelog.json | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/package.json b/app/package.json index faa175bed32..3353980ac2c 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.5.4-beta2", + "version": "3.5.4", "main": "./main.js", "repository": { "type": "git", diff --git a/changelog.json b/changelog.json index 480c5acb2bf..9363c3ef607 100644 --- a/changelog.json +++ b/changelog.json @@ -1,5 +1,21 @@ { "releases": { + "3.5.4": [ + "[Fixed] Update Git LFS to 3.7.1 to address CVE-2025-26625", + "[Fixed] Check run status icons in the re-run checks dialog have a status tooltip that is accessible by screenreaders - #21191", + "[Fixed] The Whitespace hint popover appears when right-clicking diff lines while \"Hide whitespace changes\" is enabled - #20848. Thanks @zekariasasaminew!", + "[Fixed] The cancel button in the sign-in dialog is enabled after sign-in attempt - #21144. Thanks @zekariasasaminew!", + "[Fixed] The \"Update Email\" button in the \"Misattributed Commit\" popover works after login from a different account - #21176", + "[Fixed] Improve host discovery when using authenticating proxies - #19039 #19120", + "[Fixed] Fix diff search results highlights not visible on addition hunks - #21134", + "[Fixed] Add Copilot commit message generation to context menu - #21000. Thanks @zekariasasaminew!", + "[Fixed] Override system accent color for checkboxes and radio buttons - #21088", + "[Improved] The icon contrast on the pull request check run button meets minimum 3:1 contrast requirements - #21189", + "[Improved] Increased title bar height on macOS Tahoe - #21135. Thanks @berkcebi!", + "[Improved] Display line change count in PR Preview Dialog - #21126. Thanks @iammola!", + "[Improved] Allow users to skip commit message override confirmation - #21025. Thanks @ilyassesalama!", + "[Improved] Allow generating commits with Copilot in non-GitHub repositories - #20698. Thanks @schroedermarius!" + ], "3.5.4-beta2": [ "[Fixed] Check run status icons in the re-run checks dialog have a status tooltip that is accessible by screenreaders - #21191", "[Fixed] The Whitespace hint popover appears when right-clicking diff lines while \"Hide whitespace changes\" is enabled - #20848. Thanks @zekariasasaminew!", From f26dd5d5b834d97a48bb32ed76ad48318cfba5b0 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 14:43:35 +0100 Subject: [PATCH 100/865] Update changelog.json Co-authored-by: tidy-dev <75402236+tidy-dev@users.noreply.github.com> --- changelog.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.json b/changelog.json index 4987c31734e..41f78458b9a 100644 --- a/changelog.json +++ b/changelog.json @@ -2,7 +2,7 @@ "releases": { "3.5.4-beta3": [ "[Fixed] Updated Git LFS to 3.7.1 to address CVE-2025-26625 - #21223", - "[Fixed] Fix: menu bar flickering when profile modal is open by adding capture phase click listener - #21150. Thanks @zekariasasaminew!" + "[Fixed] Menu bar does not flicker when profile modal is open - #21150. Thanks @zekariasasaminew!" ], "3.5.4-beta2": [ "[Fixed] Check run status icons in the re-run checks dialog have a status tooltip that is accessible by screenreaders - #21191", From a0ec77c5a4f82e8709d14c7fc6963f160d980b77 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 15:29:31 +0100 Subject: [PATCH 101/865] Exclude more Git env vars from hook environment Added 'GIT_EXEC_PATH' and 'GIT_TEMPLATE_DIR' to the set of excluded environment variables to prevent leaking Dugite-specific settings into hook environments. --- app/src/lib/hooks/hooks-proxy.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index d46a42402db..b51e0ff8f2a 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -88,9 +88,10 @@ export const createHooksProxy = (repoHooks: string[], tmpDir: string) => { : basename(proxyArgs[0]) const excludedEnvVars = new Set([ - // Dugite sets this to point to a custom git config file which - // we don't want to leak into the hook's environment + // Dugite sets these, we don't want to leak them into the hook environment 'GIT_SYSTEM_CONFIG', + 'GIT_EXEC_PATH', + 'GIT_TEMPLATE_DIR', // We set this to point to a custom hooks path which we don't want // leaking into the hook's environment. Initially I thought we would have // to sanitize this to strip out the custom config we set and leave any From 1d172ce0619c435baf847e38de2f3f11434cb194 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 15:29:58 +0100 Subject: [PATCH 102/865] The temp dir is already 0o600 --- app/src/lib/hooks/hooks-proxy.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index b51e0ff8f2a..723f0a0126c 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -123,18 +123,11 @@ export const createHooksProxy = (repoHooks: string[], tmpDir: string) => { // We don't have to clean this up since it's in the tmpdir created by the // hooks env. - const stdinFilePath = join( - tmpDir, - `${hookName}-stdin-${randomBytes(8).toString('hex')}` - ) - + const stdinFilePath = join(tmpDir, `in-${randomBytes(8).toString('hex')}`) const hasStdin = hooksUsingStdin.includes(hookName) if (hasStdin) { - await pipeline( - connection.stdin, - createWriteStream(stdinFilePath, { mode: 0o600 }) - ) + await pipeline(connection.stdin, createWriteStream(stdinFilePath)) } const { shell, args: shellArgs, quote } = getShell() From 5295099fa5d4f21af628a5466233cfc39c42e533 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 15:44:01 +0100 Subject: [PATCH 103/865] Update hooks-proxy.ts --- app/src/lib/hooks/hooks-proxy.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 723f0a0126c..ecad412176c 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -121,8 +121,7 @@ export const createHooksProxy = (repoHooks: string[], tmpDir: string) => { return } - // We don't have to clean this up since it's in the tmpdir created by the - // hooks env. + // tmpdir is deleted when the Git call completes, so we can leave the file const stdinFilePath = join(tmpDir, `in-${randomBytes(8).toString('hex')}`) const hasStdin = hooksUsingStdin.includes(hookName) From ec1c342c6488fce3e051c173c54cf8b118a4badb Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 13:52:01 +0100 Subject: [PATCH 104/865] Add printenvz native module and build artifacts Introduces the vendor/printenvz directory containing a minimal Node.js native module for printing environment variables separated by null bytes. Includes C++ source, node-gyp build configuration, build scripts, TypeScript definitions, package metadata, and prebuilt binaries for cross-platform support. --- vendor/printenvz/README.md | 87 ++ vendor/printenvz/binding.gyp | 23 + vendor/printenvz/build/Makefile | 347 +++++ .../obj.target/printenvz/src/printenvz.o.d | 3 + .../build/Release/.deps/Release/printenvz.d | 1 + .../obj.target/printenvz/src/printenvz.o | Bin 0 -> 101496 bytes vendor/printenvz/build/Release/printenvz | Bin 0 -> 51976 bytes vendor/printenvz/build/binding.Makefile | 6 + vendor/printenvz/build/config.gypi | 522 +++++++ vendor/printenvz/build/gyp-mac-tool | 772 ++++++++++ vendor/printenvz/build/printenvz.target.mk | 184 +++ vendor/printenvz/index.d.ts | 4 + vendor/printenvz/index.js | 22 + vendor/printenvz/package-lock.json | 1269 +++++++++++++++++ vendor/printenvz/package.json | 23 + vendor/printenvz/src/printenvz.cc | 15 + 16 files changed, 3278 insertions(+) create mode 100644 vendor/printenvz/README.md create mode 100644 vendor/printenvz/binding.gyp create mode 100644 vendor/printenvz/build/Makefile create mode 100644 vendor/printenvz/build/Release/.deps/Release/obj.target/printenvz/src/printenvz.o.d create mode 100644 vendor/printenvz/build/Release/.deps/Release/printenvz.d create mode 100644 vendor/printenvz/build/Release/obj.target/printenvz/src/printenvz.o create mode 100755 vendor/printenvz/build/Release/printenvz create mode 100644 vendor/printenvz/build/binding.Makefile create mode 100644 vendor/printenvz/build/config.gypi create mode 100755 vendor/printenvz/build/gyp-mac-tool create mode 100644 vendor/printenvz/build/printenvz.target.mk create mode 100644 vendor/printenvz/index.d.ts create mode 100644 vendor/printenvz/index.js create mode 100644 vendor/printenvz/package-lock.json create mode 100644 vendor/printenvz/package.json create mode 100644 vendor/printenvz/src/printenvz.cc diff --git a/vendor/printenvz/README.md b/vendor/printenvz/README.md new file mode 100644 index 00000000000..73150b73eb3 --- /dev/null +++ b/vendor/printenvz/README.md @@ -0,0 +1,87 @@ +# printenvz + +A minimal Node.js native module that provides a compiled executable to output all environment variables to stdout, separated by null bytes (`\0`). + +## Features + +- **Native Executable**: Compiled C++ binary using node-gyp +- **Environment Variable Output**: Prints all environment variables separated by null bytes +- **JavaScript Interface**: Simple function to get the path to the compiled binary +- **TypeScript Support**: Includes TypeScript declaration file +- **Cross-platform**: Works on macOS, Linux, and Windows + +## Installation + +```bash +npm install +``` + +This will automatically build the native executable using node-gyp. + +## Usage + +### JavaScript/Node.js + +```javascript +const { getPrintenvzPath } = require('printenvz'); + +// Get the path to the compiled native executable +const executablePath = getPrintenvzPath(); +console.log(executablePath); +// Output: /path/to/printenvz/build/Release/printenvz + +// Use with child_process to run the executable +const { execSync } = require('child_process'); +const output = execSync(executablePath, { encoding: 'buffer' }); + +// Parse environment variables (split by null bytes) +const envVars = output.toString('utf8').split('\0').filter(s => s.length > 0); +console.log(envVars); +``` + +### TypeScript + +```typescript +import { getPrintenvzPath } from 'printenvz'; + +const executablePath: string = getPrintenvzPath(); +``` + +### Direct Executable Usage + +```bash +./build/Release/printenvz | hexdump -C +``` + +## API + +### `getPrintenvzPath(): string` + +Returns the absolute path to the compiled native `printenvz` executable. + +**Returns:** `string` - The path to the executable + +## Build Commands + +- `npm run build` - Build the native module +- `npm run rebuild` - Clean and rebuild the native module +- `npm run clean` - Clean build artifacts + +## Files + +- `src/printenvz.cc` - C++ source code for the native executable +- `binding.gyp` - node-gyp build configuration +- `index.js` - JavaScript module with `getPrintenvzPath` function +- `index.d.ts` - TypeScript declaration file +- `package.json` - npm package configuration + +## How it Works + +1. The C++ source (`src/printenvz.cc`) iterates through the global `environ` variable +2. Each environment variable is printed to stdout followed by a null byte (`\0`) +3. node-gyp compiles this into a native executable during `npm install` +4. The JavaScript module provides a helper function to locate the executable path + +## License + +MIT diff --git a/vendor/printenvz/binding.gyp b/vendor/printenvz/binding.gyp new file mode 100644 index 00000000000..03777d3988e --- /dev/null +++ b/vendor/printenvz/binding.gyp @@ -0,0 +1,23 @@ +{ + "targets": [ + { + "target_name": "printenvz", + "type": "executable", + "sources": [ + "src/printenvz.cc" + ], + "include_dirs": [], + "cflags": ["-std=c++11"], + "cflags_cc": ["-std=c++11"], + "conditions": [ + ["OS=='mac'", { + "xcode_settings": { + "OTHER_CPLUSPLUSFLAGS": ["-std=c++11", "-stdlib=libc++"], + "OTHER_LDFLAGS": ["-stdlib=libc++"], + "MACOSX_DEPLOYMENT_TARGET": "10.7" + } + }] + ] + } + ] +} diff --git a/vendor/printenvz/build/Makefile b/vendor/printenvz/build/Makefile new file mode 100644 index 00000000000..4036c577663 --- /dev/null +++ b/vendor/printenvz/build/Makefile @@ -0,0 +1,347 @@ +# We borrow heavily from the kernel build setup, though we are simpler since +# we don't have Kconfig tweaking settings on us. + +# The implicit make rules have it looking for RCS files, among other things. +# We instead explicitly write all the rules we care about. +# It's even quicker (saves ~200ms) to pass -r on the command line. +MAKEFLAGS=-r + +# The source directory tree. +srcdir := .. +abs_srcdir := $(abspath $(srcdir)) + +# The name of the builddir. +builddir_name ?= . + +# The V=1 flag on command line makes us verbosely print command lines. +ifdef V + quiet= +else + quiet=quiet_ +endif + +# Specify BUILDTYPE=Release on the command line for a release build. +BUILDTYPE ?= Release + +# Directory all our build output goes into. +# Note that this must be two directories beneath src/ for unit tests to pass, +# as they reach into the src/ directory for data with relative paths. +builddir ?= $(builddir_name)/$(BUILDTYPE) +abs_builddir := $(abspath $(builddir)) +depsdir := $(builddir)/.deps + +# Object output directory. +obj := $(builddir)/obj +abs_obj := $(abspath $(obj)) + +# We build up a list of every single one of the targets so we can slurp in the +# generated dependency rule Makefiles in one pass. +all_deps := + + + +CC.target ?= $(CC) +CFLAGS.target ?= $(CPPFLAGS) $(CFLAGS) +CXX.target ?= $(CXX) +CXXFLAGS.target ?= $(CPPFLAGS) $(CXXFLAGS) +LINK.target ?= $(LINK) +LDFLAGS.target ?= $(LDFLAGS) +AR.target ?= $(AR) +PLI.target ?= pli + +# C++ apps need to be linked with g++. +LINK ?= $(CXX.target) + +# TODO(evan): move all cross-compilation logic to gyp-time so we don't need +# to replicate this environment fallback in make as well. +CC.host ?= gcc +CFLAGS.host ?= $(CPPFLAGS_host) $(CFLAGS_host) +CXX.host ?= g++ +CXXFLAGS.host ?= $(CPPFLAGS_host) $(CXXFLAGS_host) +LINK.host ?= $(CXX.host) +LDFLAGS.host ?= $(LDFLAGS_host) +AR.host ?= ar +PLI.host ?= pli + +# Define a dir function that can handle spaces. +# http://www.gnu.org/software/make/manual/make.html#Syntax-of-Functions +# "leading spaces cannot appear in the text of the first argument as written. +# These characters can be put into the argument value by variable substitution." +empty := +space := $(empty) $(empty) + +# http://stackoverflow.com/questions/1189781/using-make-dir-or-notdir-on-a-path-with-spaces +replace_spaces = $(subst $(space),?,$1) +unreplace_spaces = $(subst ?,$(space),$1) +dirx = $(call unreplace_spaces,$(dir $(call replace_spaces,$1))) + +# Flags to make gcc output dependency info. Note that you need to be +# careful here to use the flags that ccache and distcc can understand. +# We write to a dep file on the side first and then rename at the end +# so we can't end up with a broken dep file. +depfile = $(depsdir)/$(call replace_spaces,$@).d +DEPFLAGS = -MMD -MF $(depfile).raw + +# We have to fixup the deps output in a few ways. +# (1) the file output should mention the proper .o file. +# ccache or distcc lose the path to the target, so we convert a rule of +# the form: +# foobar.o: DEP1 DEP2 +# into +# path/to/foobar.o: DEP1 DEP2 +# (2) we want missing files not to cause us to fail to build. +# We want to rewrite +# foobar.o: DEP1 DEP2 \ +# DEP3 +# to +# DEP1: +# DEP2: +# DEP3: +# so if the files are missing, they're just considered phony rules. +# We have to do some pretty insane escaping to get those backslashes +# and dollar signs past make, the shell, and sed at the same time. +# Doesn't work with spaces, but that's fine: .d files have spaces in +# their names replaced with other characters. +define fixup_dep +# The depfile may not exist if the input file didn't have any #includes. +touch $(depfile).raw +# Fixup path as in (1). +sed -e "s|^$(notdir $@)|$@|" $(depfile).raw >> $(depfile) +# Add extra rules as in (2). +# We remove slashes and replace spaces with new lines; +# remove blank lines; +# delete the first line and append a colon to the remaining lines. +sed -e 's|\\||' -e 'y| |\n|' $(depfile).raw |\ + grep -v '^$$' |\ + sed -e 1d -e 's|$$|:|' \ + >> $(depfile) +rm $(depfile).raw +endef + +# Command definitions: +# - cmd_foo is the actual command to run; +# - quiet_cmd_foo is the brief-output summary of the command. + +quiet_cmd_cc = CC($(TOOLSET)) $@ +cmd_cc = $(CC.$(TOOLSET)) -o $@ $< $(GYP_CFLAGS) $(DEPFLAGS) $(CFLAGS.$(TOOLSET)) -c + +quiet_cmd_cxx = CXX($(TOOLSET)) $@ +cmd_cxx = $(CXX.$(TOOLSET)) -o $@ $< $(GYP_CXXFLAGS) $(DEPFLAGS) $(CXXFLAGS.$(TOOLSET)) -c + +quiet_cmd_objc = CXX($(TOOLSET)) $@ +cmd_objc = $(CC.$(TOOLSET)) $(GYP_OBJCFLAGS) $(DEPFLAGS) -c -o $@ $< + +quiet_cmd_objcxx = CXX($(TOOLSET)) $@ +cmd_objcxx = $(CXX.$(TOOLSET)) $(GYP_OBJCXXFLAGS) $(DEPFLAGS) -c -o $@ $< + +# Commands for precompiled header files. +quiet_cmd_pch_c = CXX($(TOOLSET)) $@ +cmd_pch_c = $(CC.$(TOOLSET)) $(GYP_PCH_CFLAGS) $(DEPFLAGS) $(CXXFLAGS.$(TOOLSET)) -c -o $@ $< +quiet_cmd_pch_cc = CXX($(TOOLSET)) $@ +cmd_pch_cc = $(CC.$(TOOLSET)) $(GYP_PCH_CXXFLAGS) $(DEPFLAGS) $(CXXFLAGS.$(TOOLSET)) -c -o $@ $< +quiet_cmd_pch_m = CXX($(TOOLSET)) $@ +cmd_pch_m = $(CC.$(TOOLSET)) $(GYP_PCH_OBJCFLAGS) $(DEPFLAGS) -c -o $@ $< +quiet_cmd_pch_mm = CXX($(TOOLSET)) $@ +cmd_pch_mm = $(CC.$(TOOLSET)) $(GYP_PCH_OBJCXXFLAGS) $(DEPFLAGS) -c -o $@ $< + +# gyp-mac-tool is written next to the root Makefile by gyp. +# Use $(4) for the command, since $(2) and $(3) are used as flag by do_cmd +# already. +quiet_cmd_mac_tool = MACTOOL $(4) $< +cmd_mac_tool = ./gyp-mac-tool $(4) $< "$@" + +quiet_cmd_mac_package_framework = PACKAGE FRAMEWORK $@ +cmd_mac_package_framework = ./gyp-mac-tool package-framework "$@" $(4) + +quiet_cmd_infoplist = INFOPLIST $@ +cmd_infoplist = $(CC.$(TOOLSET)) -E -P -Wno-trigraphs -x c $(INFOPLIST_DEFINES) "$<" -o "$@" + +quiet_cmd_touch = TOUCH $@ +cmd_touch = touch $@ + +quiet_cmd_copy = COPY $@ +# send stderr to /dev/null to ignore messages when linking directories. +cmd_copy = ln -f "$<" "$@" 2>/dev/null || (rm -rf "$@" && cp -af "$<" "$@") + +quiet_cmd_symlink = SYMLINK $@ +cmd_symlink = ln -sf "$<" "$@" + +quiet_cmd_alink = LIBTOOL-STATIC $@ +cmd_alink = rm -f $@ && ./gyp-mac-tool filter-libtool libtool $(GYP_LIBTOOLFLAGS) -static -o $@ $(filter %.o,$^) + +quiet_cmd_link = LINK($(TOOLSET)) $@ +cmd_link = $(LINK.$(TOOLSET)) $(GYP_LDFLAGS) $(LDFLAGS.$(TOOLSET)) -o "$@" $(LD_INPUTS) $(LIBS) + +quiet_cmd_solink = SOLINK($(TOOLSET)) $@ +cmd_solink = $(LINK.$(TOOLSET)) -shared $(GYP_LDFLAGS) $(LDFLAGS.$(TOOLSET)) -o "$@" $(LD_INPUTS) $(LIBS) + +quiet_cmd_solink_module = SOLINK_MODULE($(TOOLSET)) $@ +cmd_solink_module = $(LINK.$(TOOLSET)) -bundle $(GYP_LDFLAGS) $(LDFLAGS.$(TOOLSET)) -o $@ $(filter-out FORCE_DO_CMD, $^) $(LIBS) + + +# Define an escape_quotes function to escape single quotes. +# This allows us to handle quotes properly as long as we always use +# use single quotes and escape_quotes. +escape_quotes = $(subst ','\'',$(1)) +# This comment is here just to include a ' to unconfuse syntax highlighting. +# Define an escape_vars function to escape '$' variable syntax. +# This allows us to read/write command lines with shell variables (e.g. +# $LD_LIBRARY_PATH), without triggering make substitution. +escape_vars = $(subst $$,$$$$,$(1)) +# Helper that expands to a shell command to echo a string exactly as it is in +# make. This uses printf instead of echo because printf's behaviour with respect +# to escape sequences is more portable than echo's across different shells +# (e.g., dash, bash). +exact_echo = printf '%s\n' '$(call escape_quotes,$(1))' + +# Helper to compare the command we're about to run against the command +# we logged the last time we ran the command. Produces an empty +# string (false) when the commands match. +# Tricky point: Make has no string-equality test function. +# The kernel uses the following, but it seems like it would have false +# positives, where one string reordered its arguments. +# arg_check = $(strip $(filter-out $(cmd_$(1)), $(cmd_$@)) \ +# $(filter-out $(cmd_$@), $(cmd_$(1)))) +# We instead substitute each for the empty string into the other, and +# say they're equal if both substitutions produce the empty string. +# .d files contain ? instead of spaces, take that into account. +command_changed = $(or $(subst $(cmd_$(1)),,$(cmd_$(call replace_spaces,$@))),\ + $(subst $(cmd_$(call replace_spaces,$@)),,$(cmd_$(1)))) + +# Helper that is non-empty when a prerequisite changes. +# Normally make does this implicitly, but we force rules to always run +# so we can check their command lines. +# $? -- new prerequisites +# $| -- order-only dependencies +prereq_changed = $(filter-out FORCE_DO_CMD,$(filter-out $|,$?)) + +# Helper that executes all postbuilds until one fails. +define do_postbuilds + @E=0;\ + for p in $(POSTBUILDS); do\ + eval $$p;\ + E=$$?;\ + if [ $$E -ne 0 ]; then\ + break;\ + fi;\ + done;\ + if [ $$E -ne 0 ]; then\ + rm -rf "$@";\ + exit $$E;\ + fi +endef + +# do_cmd: run a command via the above cmd_foo names, if necessary. +# Should always run for a given target to handle command-line changes. +# Second argument, if non-zero, makes it do asm/C/C++ dependency munging. +# Third argument, if non-zero, makes it do POSTBUILDS processing. +# Note: We intentionally do NOT call dirx for depfile, since it contains ? for +# spaces already and dirx strips the ? characters. +define do_cmd +$(if $(or $(command_changed),$(prereq_changed)), + @$(call exact_echo, $($(quiet)cmd_$(1))) + @mkdir -p "$(call dirx,$@)" "$(dir $(depfile))" + $(if $(findstring flock,$(word 2,$(cmd_$1))), + @$(cmd_$(1)) + @echo " $(quiet_cmd_$(1)): Finished", + @$(cmd_$(1)) + ) + @$(call exact_echo,$(call escape_vars,cmd_$(call replace_spaces,$@) := $(cmd_$(1)))) > $(depfile) + @$(if $(2),$(fixup_dep)) + $(if $(and $(3), $(POSTBUILDS)), + $(call do_postbuilds) + ) +) +endef + +# Declare the "all" target first so it is the default, +# even though we don't have the deps yet. +.PHONY: all +all: + +# make looks for ways to re-generate included makefiles, but in our case, we +# don't have a direct way. Explicitly telling make that it has nothing to do +# for them makes it go faster. +%.d: ; + +# Use FORCE_DO_CMD to force a target to run. Should be coupled with +# do_cmd. +.PHONY: FORCE_DO_CMD +FORCE_DO_CMD: + +TOOLSET := target +# Suffix rules, putting all outputs into $(obj). +$(obj).$(TOOLSET)/%.o: $(srcdir)/%.c FORCE_DO_CMD + @$(call do_cmd,cc,1) +$(obj).$(TOOLSET)/%.o: $(srcdir)/%.cc FORCE_DO_CMD + @$(call do_cmd,cxx,1) +$(obj).$(TOOLSET)/%.o: $(srcdir)/%.cpp FORCE_DO_CMD + @$(call do_cmd,cxx,1) +$(obj).$(TOOLSET)/%.o: $(srcdir)/%.cxx FORCE_DO_CMD + @$(call do_cmd,cxx,1) +$(obj).$(TOOLSET)/%.o: $(srcdir)/%.m FORCE_DO_CMD + @$(call do_cmd,objc,1) +$(obj).$(TOOLSET)/%.o: $(srcdir)/%.mm FORCE_DO_CMD + @$(call do_cmd,objcxx,1) +$(obj).$(TOOLSET)/%.o: $(srcdir)/%.s FORCE_DO_CMD + @$(call do_cmd,cc,1) +$(obj).$(TOOLSET)/%.o: $(srcdir)/%.S FORCE_DO_CMD + @$(call do_cmd,cc,1) + +# Try building from generated source, too. +$(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.c FORCE_DO_CMD + @$(call do_cmd,cc,1) +$(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.cc FORCE_DO_CMD + @$(call do_cmd,cxx,1) +$(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.cpp FORCE_DO_CMD + @$(call do_cmd,cxx,1) +$(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.cxx FORCE_DO_CMD + @$(call do_cmd,cxx,1) +$(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.m FORCE_DO_CMD + @$(call do_cmd,objc,1) +$(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.mm FORCE_DO_CMD + @$(call do_cmd,objcxx,1) +$(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.s FORCE_DO_CMD + @$(call do_cmd,cc,1) +$(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.S FORCE_DO_CMD + @$(call do_cmd,cc,1) + +$(obj).$(TOOLSET)/%.o: $(obj)/%.c FORCE_DO_CMD + @$(call do_cmd,cc,1) +$(obj).$(TOOLSET)/%.o: $(obj)/%.cc FORCE_DO_CMD + @$(call do_cmd,cxx,1) +$(obj).$(TOOLSET)/%.o: $(obj)/%.cpp FORCE_DO_CMD + @$(call do_cmd,cxx,1) +$(obj).$(TOOLSET)/%.o: $(obj)/%.cxx FORCE_DO_CMD + @$(call do_cmd,cxx,1) +$(obj).$(TOOLSET)/%.o: $(obj)/%.m FORCE_DO_CMD + @$(call do_cmd,objc,1) +$(obj).$(TOOLSET)/%.o: $(obj)/%.mm FORCE_DO_CMD + @$(call do_cmd,objcxx,1) +$(obj).$(TOOLSET)/%.o: $(obj)/%.s FORCE_DO_CMD + @$(call do_cmd,cc,1) +$(obj).$(TOOLSET)/%.o: $(obj)/%.S FORCE_DO_CMD + @$(call do_cmd,cc,1) + + +ifeq ($(strip $(foreach prefix,$(NO_LOAD),\ + $(findstring $(join ^,$(prefix)),\ + $(join ^,printenvz.target.mk)))),) + include printenvz.target.mk +endif + +quiet_cmd_regen_makefile = ACTION Regenerating $@ +cmd_regen_makefile = cd $(srcdir); /Users/markus/GitHub/desktop/vendor/printenvz/node_modules/node-gyp/gyp/gyp_main.py -fmake --ignore-environment "-Dlibrary=shared_library" "-Dvisibility=default" "-Dnode_root_dir=/Users/markus/Library/Caches/node-gyp/24.9.0" "-Dnode_gyp_dir=/Users/markus/GitHub/desktop/vendor/printenvz/node_modules/node-gyp" "-Dnode_lib_file=/Users/markus/Library/Caches/node-gyp/24.9.0/<(target_arch)/node.lib" "-Dmodule_root_dir=/Users/markus/GitHub/desktop/vendor/printenvz" "-Dnode_engine=v8" "--depth=." "-Goutput_dir=." "--generator-output=build" -I/Users/markus/GitHub/desktop/vendor/printenvz/build/config.gypi -I/Users/markus/GitHub/desktop/vendor/printenvz/node_modules/node-gyp/addon.gypi -I/Users/markus/Library/Caches/node-gyp/24.9.0/include/node/common.gypi "--toplevel-dir=." binding.gyp +Makefile: $(srcdir)/../../../../Library/Caches/node-gyp/24.9.0/include/node/common.gypi $(srcdir)/node_modules/node-gyp/addon.gypi $(srcdir)/build/config.gypi $(srcdir)/binding.gyp + $(call do_cmd,regen_makefile) + +# "all" is a concatenation of the "all" targets from all the included +# sub-makefiles. This is just here to clarify. +all: + +# Add in dependency-tracking rules. $(all_deps) is the list of every single +# target in our tree. Only consider the ones with .d (dependency) info: +d_files := $(wildcard $(foreach f,$(all_deps),$(depsdir)/$(f).d)) +ifneq ($(d_files),) + include $(d_files) +endif diff --git a/vendor/printenvz/build/Release/.deps/Release/obj.target/printenvz/src/printenvz.o.d b/vendor/printenvz/build/Release/.deps/Release/obj.target/printenvz/src/printenvz.o.d new file mode 100644 index 00000000000..75ff6c1adf1 --- /dev/null +++ b/vendor/printenvz/build/Release/.deps/Release/obj.target/printenvz/src/printenvz.o.d @@ -0,0 +1,3 @@ +cmd_Release/obj.target/printenvz/src/printenvz.o := c++ -o Release/obj.target/printenvz/src/printenvz.o ../src/printenvz.cc '-DNODE_GYP_MODULE_NAME=printenvz' '-DUSING_UV_SHARED=1' '-DUSING_V8_SHARED=1' '-DV8_DEPRECATION_WARNINGS=1' '-D_GLIBCXX_USE_CXX11_ABI=1' '-D_FILE_OFFSET_BITS=64' '-D_DARWIN_USE_64_BIT_INODE=1' '-D_LARGEFILE_SOURCE' '-DOPENSSL_NO_PINSHARED' '-DOPENSSL_THREADS' -I/Users/markus/Library/Caches/node-gyp/24.9.0/include/node -I/Users/markus/Library/Caches/node-gyp/24.9.0/src -I/Users/markus/Library/Caches/node-gyp/24.9.0/deps/openssl/config -I/Users/markus/Library/Caches/node-gyp/24.9.0/deps/openssl/openssl/include -I/Users/markus/Library/Caches/node-gyp/24.9.0/deps/uv/include -I/Users/markus/Library/Caches/node-gyp/24.9.0/deps/zlib -I/Users/markus/Library/Caches/node-gyp/24.9.0/deps/v8/include -O3 -gdwarf-2 -fno-strict-aliasing -mmacosx-version-min=10.7 -arch arm64 -Wall -Wendif-labels -W -Wno-unused-parameter -std=gnu++20 -stdlib=libc++ -fno-rtti -fno-exceptions -std=c++11 -stdlib=libc++ -MMD -MF ./Release/.deps/Release/obj.target/printenvz/src/printenvz.o.d.raw -c +Release/obj.target/printenvz/src/printenvz.o: ../src/printenvz.cc +../src/printenvz.cc: diff --git a/vendor/printenvz/build/Release/.deps/Release/printenvz.d b/vendor/printenvz/build/Release/.deps/Release/printenvz.d new file mode 100644 index 00000000000..c3dd91cac25 --- /dev/null +++ b/vendor/printenvz/build/Release/.deps/Release/printenvz.d @@ -0,0 +1 @@ +cmd_Release/printenvz := c++ -stdlib=libc++ -Wl,-search_paths_first -mmacosx-version-min=10.7 -arch arm64 -L./Release -stdlib=libc++ -o "Release/printenvz" ./Release/obj.target/printenvz/src/printenvz.o diff --git a/vendor/printenvz/build/Release/obj.target/printenvz/src/printenvz.o b/vendor/printenvz/build/Release/obj.target/printenvz/src/printenvz.o new file mode 100644 index 0000000000000000000000000000000000000000..0efb42aa79f06fcf09d858c8a4a4cf6dca228d82 GIT binary patch literal 101496 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

tft$R|lbf7bs%(zv=%*R9&z9FLM8xh3` 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 "0700": + args.extend(["--auto-activate-custom-fonts"]) + if "IPHONEOS_DEPLOYMENT_TARGET" in os.environ: + args.extend( + [ + "--target-device", + "iphone", + "--target-device", + "ipad", + "--minimum-deployment-target", + os.environ["IPHONEOS_DEPLOYMENT_TARGET"], + ] + ) + else: + args.extend( + [ + "--target-device", + "mac", + "--minimum-deployment-target", + os.environ["MACOSX_DEPLOYMENT_TARGET"], + ] + ) + + args.extend( + ["--output-format", "human-readable-text", "--compile", dest, source] + ) + + ibtool_section_re = re.compile(r"/\*.*\*/") + ibtool_re = re.compile(r".*note:.*is clipping its content") + try: + stdout = subprocess.check_output(args) + except subprocess.CalledProcessError as e: + print(e.output) + raise + current_section_header = None + for line in stdout.splitlines(): + if ibtool_section_re.match(line): + current_section_header = line + elif not ibtool_re.match(line): + if current_section_header: + print(current_section_header) + current_section_header = None + print(line) + return 0 + + def _ConvertToBinary(self, dest): + subprocess.check_call( + ["xcrun", "plutil", "-convert", "binary1", "-o", dest, dest] + ) + + def _CopyStringsFile(self, source, dest): + """Copies a .strings file using iconv to reconvert the input into UTF-16.""" + input_code = self._DetectInputEncoding(source) or "UTF-8" + + # Xcode's CpyCopyStringsFile / builtin-copyStrings seems to call + # CFPropertyListCreateFromXMLData() behind the scenes; at least it prints + # CFPropertyListCreateFromXMLData(): Old-style plist parser: missing + # semicolon in dictionary. + # on invalid files. Do the same kind of validation. + import CoreFoundation + + with open(source, "rb") as in_file: + s = in_file.read() + d = CoreFoundation.CFDataCreate(None, s, len(s)) + _, error = CoreFoundation.CFPropertyListCreateFromXMLData(None, d, 0, None) + if error: + return + + with open(dest, "wb") as fp: + fp.write(s.decode(input_code).encode("UTF-16")) + + def _DetectInputEncoding(self, file_name): + """Reads the first few bytes from file_name and tries to guess the text + encoding. Returns None as a guess if it can't detect it.""" + with open(file_name, "rb") as fp: + try: + header = fp.read(3) + except Exception: + return None + if header.startswith(b"\xFE\xFF"): + return "UTF-16" + elif header.startswith(b"\xFF\xFE"): + return "UTF-16" + elif header.startswith(b"\xEF\xBB\xBF"): + return "UTF-8" + else: + return None + + def ExecCopyInfoPlist(self, source, dest, convert_to_binary, *keys): + """Copies the |source| Info.plist to the destination directory |dest|.""" + # Read the source Info.plist into memory. + with open(source) as fd: + lines = fd.read() + + # Insert synthesized key/value pairs (e.g. BuildMachineOSBuild). + plist = plistlib.readPlistFromString(lines) + if keys: + plist.update(json.loads(keys[0])) + lines = plistlib.writePlistToString(plist) + + # Go through all the environment variables and replace them as variables in + # the file. + IDENT_RE = re.compile(r"[_/\s]") + for key in os.environ: + if key.startswith("_"): + continue + evar = "${%s}" % key + evalue = os.environ[key] + lines = lines.replace(lines, evar, evalue) + + # Xcode supports various suffices on environment variables, which are + # all undocumented. :rfc1034identifier is used in the standard project + # template these days, and :identifier was used earlier. They are used to + # convert non-url characters into things that look like valid urls -- + # except that the replacement character for :identifier, '_' isn't valid + # in a URL either -- oops, hence :rfc1034identifier was born. + evar = "${%s:identifier}" % key + evalue = IDENT_RE.sub("_", os.environ[key]) + lines = lines.replace(lines, evar, evalue) + + evar = "${%s:rfc1034identifier}" % key + evalue = IDENT_RE.sub("-", os.environ[key]) + lines = lines.replace(lines, evar, evalue) + + # Remove any keys with values that haven't been replaced. + lines = lines.splitlines() + for i in range(len(lines)): + if lines[i].strip().startswith("${"): + lines[i] = None + lines[i - 1] = None + lines = "\n".join(line for line in lines if line is not None) + + # Write out the file with variables replaced. + with open(dest, "w") as fd: + fd.write(lines) + + # Now write out PkgInfo file now that the Info.plist file has been + # "compiled". + self._WritePkgInfo(dest) + + if convert_to_binary == "True": + self._ConvertToBinary(dest) + + def _WritePkgInfo(self, info_plist): + """This writes the PkgInfo file from the data stored in Info.plist.""" + plist = plistlib.readPlist(info_plist) + if not plist: + return + + # Only create PkgInfo for executable types. + package_type = plist["CFBundlePackageType"] + if package_type != "APPL": + return + + # The format of PkgInfo is eight characters, representing the bundle type + # and bundle signature, each four characters. If that is missing, four + # '?' characters are used instead. + signature_code = plist.get("CFBundleSignature", "????") + if len(signature_code) != 4: # Wrong length resets everything, too. + signature_code = "?" * 4 + + dest = os.path.join(os.path.dirname(info_plist), "PkgInfo") + with open(dest, "w") as fp: + fp.write(f"{package_type}{signature_code}") + + def ExecFlock(self, lockfile, *cmd_list): + """Emulates the most basic behavior of Linux's flock(1).""" + # Rely on exception handling to report errors. + fd = os.open(lockfile, os.O_RDONLY | os.O_NOCTTY | os.O_CREAT, 0o666) + fcntl.flock(fd, fcntl.LOCK_EX) + return subprocess.call(cmd_list) + + def ExecFilterLibtool(self, *cmd_list): + """Calls libtool and filters out '/path/to/libtool: file: foo.o has no + symbols'.""" + libtool_re = re.compile( + r"^.*libtool: (?:for architecture: \S* )?" r"file: .* has no symbols$" + ) + libtool_re5 = re.compile( + r"^.*libtool: warning for library: " + + r".* the table of contents is empty " + + r"\(no object file members in the library define global symbols\)$" + ) + env = os.environ.copy() + # Ref: + # http://www.opensource.apple.com/source/cctools/cctools-809/misc/libtool.c + # The problem with this flag is that it resets the file mtime on the file to + # epoch=0, e.g. 1970-1-1 or 1969-12-31 depending on timezone. + env["ZERO_AR_DATE"] = "1" + libtoolout = subprocess.Popen(cmd_list, stderr=subprocess.PIPE, env=env) + err = libtoolout.communicate()[1].decode("utf-8") + for line in err.splitlines(): + if not libtool_re.match(line) and not libtool_re5.match(line): + print(line, file=sys.stderr) + # Unconditionally touch the output .a file on the command line if present + # and the command succeeded. A bit hacky. + if not libtoolout.returncode: + for i in range(len(cmd_list) - 1): + if cmd_list[i] == "-o" and cmd_list[i + 1].endswith(".a"): + os.utime(cmd_list[i + 1], None) + break + return libtoolout.returncode + + def ExecPackageIosFramework(self, framework): + # Find the name of the binary based on the part before the ".framework". + binary = os.path.basename(framework).split(".")[0] + module_path = os.path.join(framework, "Modules") + if not os.path.exists(module_path): + os.mkdir(module_path) + module_template = ( + "framework module %s {\n" + ' umbrella header "%s.h"\n' + "\n" + " export *\n" + " module * { export * }\n" + "}\n" % (binary, binary) + ) + + with open(os.path.join(module_path, "module.modulemap"), "w") as module_file: + module_file.write(module_template) + + def ExecPackageFramework(self, framework, version): + """Takes a path to Something.framework and the Current version of that and + sets up all the symlinks.""" + # Find the name of the binary based on the part before the ".framework". + binary = os.path.basename(framework).split(".")[0] + + CURRENT = "Current" + RESOURCES = "Resources" + VERSIONS = "Versions" + + if not os.path.exists(os.path.join(framework, VERSIONS, version, binary)): + # Binary-less frameworks don't seem to contain symlinks (see e.g. + # chromium's out/Debug/org.chromium.Chromium.manifest/ bundle). + return + + # Move into the framework directory to set the symlinks correctly. + pwd = os.getcwd() + os.chdir(framework) + + # Set up the Current version. + self._Relink(version, os.path.join(VERSIONS, CURRENT)) + + # Set up the root symlinks. + self._Relink(os.path.join(VERSIONS, CURRENT, binary), binary) + self._Relink(os.path.join(VERSIONS, CURRENT, RESOURCES), RESOURCES) + + # Back to where we were before! + os.chdir(pwd) + + def _Relink(self, dest, link): + """Creates a symlink to |dest| named |link|. If |link| already exists, + it is overwritten.""" + if os.path.lexists(link): + os.remove(link) + os.symlink(dest, link) + + def ExecCompileIosFrameworkHeaderMap(self, out, framework, *all_headers): + framework_name = os.path.basename(framework).split(".")[0] + all_headers = [os.path.abspath(header) for header in all_headers] + filelist = {} + for header in all_headers: + filename = os.path.basename(header) + filelist[filename] = header + filelist[os.path.join(framework_name, filename)] = header + WriteHmap(out, filelist) + + def ExecCopyIosFrameworkHeaders(self, framework, *copy_headers): + header_path = os.path.join(framework, "Headers") + if not os.path.exists(header_path): + os.makedirs(header_path) + for header in copy_headers: + shutil.copy(header, os.path.join(header_path, os.path.basename(header))) + + def ExecCompileXcassets(self, keys, *inputs): + """Compiles multiple .xcassets files into a single .car file. + + This invokes 'actool' to compile all the inputs .xcassets files. The + |keys| arguments is a json-encoded dictionary of extra arguments to + pass to 'actool' when the asset catalogs contains an application icon + or a launch image. + + Note that 'actool' does not create the Assets.car file if the asset + catalogs does not contains imageset. + """ + command_line = [ + "xcrun", + "actool", + "--output-format", + "human-readable-text", + "--compress-pngs", + "--notices", + "--warnings", + "--errors", + ] + is_iphone_target = "IPHONEOS_DEPLOYMENT_TARGET" in os.environ + if is_iphone_target: + platform = os.environ["CONFIGURATION"].split("-")[-1] + if platform not in ("iphoneos", "iphonesimulator"): + platform = "iphonesimulator" + command_line.extend( + [ + "--platform", + platform, + "--target-device", + "iphone", + "--target-device", + "ipad", + "--minimum-deployment-target", + os.environ["IPHONEOS_DEPLOYMENT_TARGET"], + "--compile", + os.path.abspath(os.environ["CONTENTS_FOLDER_PATH"]), + ] + ) + else: + command_line.extend( + [ + "--platform", + "macosx", + "--target-device", + "mac", + "--minimum-deployment-target", + os.environ["MACOSX_DEPLOYMENT_TARGET"], + "--compile", + os.path.abspath(os.environ["UNLOCALIZED_RESOURCES_FOLDER_PATH"]), + ] + ) + if keys: + keys = json.loads(keys) + for key, value in keys.items(): + arg_name = "--" + key + if isinstance(value, bool): + if value: + command_line.append(arg_name) + elif isinstance(value, list): + for v in value: + command_line.append(arg_name) + command_line.append(str(v)) + else: + command_line.append(arg_name) + command_line.append(str(value)) + # Note: actool crashes if inputs path are relative, so use os.path.abspath + # to get absolute path name for inputs. + command_line.extend(map(os.path.abspath, inputs)) + subprocess.check_call(command_line) + + def ExecMergeInfoPlist(self, output, *inputs): + """Merge multiple .plist files into a single .plist file.""" + merged_plist = {} + for path in inputs: + plist = self._LoadPlistMaybeBinary(path) + self._MergePlist(merged_plist, plist) + plistlib.writePlist(merged_plist, output) + + def ExecCodeSignBundle(self, key, entitlements, provisioning, path, preserve): + """Code sign a bundle. + + This function tries to code sign an iOS bundle, following the same + algorithm as Xcode: + 1. pick the provisioning profile that best match the bundle identifier, + and copy it into the bundle as embedded.mobileprovision, + 2. copy Entitlements.plist from user or SDK next to the bundle, + 3. code sign the bundle. + """ + substitutions, overrides = self._InstallProvisioningProfile( + provisioning, self._GetCFBundleIdentifier() + ) + entitlements_path = self._InstallEntitlements( + entitlements, substitutions, overrides + ) + + args = ["codesign", "--force", "--sign", key] + if preserve == "True": + args.extend(["--deep", "--preserve-metadata=identifier,entitlements"]) + else: + args.extend(["--entitlements", entitlements_path]) + args.extend(["--timestamp=none", path]) + subprocess.check_call(args) + + def _InstallProvisioningProfile(self, profile, bundle_identifier): + """Installs embedded.mobileprovision into the bundle. + + Args: + profile: string, optional, short name of the .mobileprovision file + to use, if empty or the file is missing, the best file installed + will be used + bundle_identifier: string, value of CFBundleIdentifier from Info.plist + + Returns: + A tuple containing two dictionary: variables substitutions and values + to overrides when generating the entitlements file. + """ + source_path, provisioning_data, team_id = self._FindProvisioningProfile( + profile, bundle_identifier + ) + target_path = os.path.join( + os.environ["BUILT_PRODUCTS_DIR"], + os.environ["CONTENTS_FOLDER_PATH"], + "embedded.mobileprovision", + ) + shutil.copy2(source_path, target_path) + substitutions = self._GetSubstitutions(bundle_identifier, team_id + ".") + return substitutions, provisioning_data["Entitlements"] + + def _FindProvisioningProfile(self, profile, bundle_identifier): + """Finds the .mobileprovision file to use for signing the bundle. + + Checks all the installed provisioning profiles (or if the user specified + the PROVISIONING_PROFILE variable, only consult it) and select the most + specific that correspond to the bundle identifier. + + Args: + profile: string, optional, short name of the .mobileprovision file + to use, if empty or the file is missing, the best file installed + will be used + bundle_identifier: string, value of CFBundleIdentifier from Info.plist + + Returns: + A tuple of the path to the selected provisioning profile, the data of + the embedded plist in the provisioning profile and the team identifier + to use for code signing. + + Raises: + SystemExit: if no .mobileprovision can be used to sign the bundle. + """ + profiles_dir = os.path.join( + os.environ["HOME"], "Library", "MobileDevice", "Provisioning Profiles" + ) + if not os.path.isdir(profiles_dir): + print( + "cannot find mobile provisioning for %s" % (bundle_identifier), + file=sys.stderr, + ) + sys.exit(1) + provisioning_profiles = None + if profile: + profile_path = os.path.join(profiles_dir, profile + ".mobileprovision") + if os.path.exists(profile_path): + provisioning_profiles = [profile_path] + if not provisioning_profiles: + provisioning_profiles = glob.glob( + os.path.join(profiles_dir, "*.mobileprovision") + ) + valid_provisioning_profiles = {} + for profile_path in provisioning_profiles: + profile_data = self._LoadProvisioningProfile(profile_path) + app_id_pattern = profile_data.get("Entitlements", {}).get( + "application-identifier", "" + ) + for team_identifier in profile_data.get("TeamIdentifier", []): + app_id = f"{team_identifier}.{bundle_identifier}" + if fnmatch.fnmatch(app_id, app_id_pattern): + valid_provisioning_profiles[app_id_pattern] = ( + profile_path, + profile_data, + team_identifier, + ) + if not valid_provisioning_profiles: + print( + "cannot find mobile provisioning for %s" % (bundle_identifier), + file=sys.stderr, + ) + sys.exit(1) + # If the user has multiple provisioning profiles installed that can be + # used for ${bundle_identifier}, pick the most specific one (ie. the + # provisioning profile whose pattern is the longest). + selected_key = max(valid_provisioning_profiles, key=lambda v: len(v)) + return valid_provisioning_profiles[selected_key] + + def _LoadProvisioningProfile(self, profile_path): + """Extracts the plist embedded in a provisioning profile. + + Args: + profile_path: string, path to the .mobileprovision file + + Returns: + Content of the plist embedded in the provisioning profile as a dictionary. + """ + with tempfile.NamedTemporaryFile() as temp: + subprocess.check_call( + ["security", "cms", "-D", "-i", profile_path, "-o", temp.name] + ) + return self._LoadPlistMaybeBinary(temp.name) + + def _MergePlist(self, merged_plist, plist): + """Merge |plist| into |merged_plist|.""" + for key, value in plist.items(): + if isinstance(value, dict): + merged_value = merged_plist.get(key, {}) + if isinstance(merged_value, dict): + self._MergePlist(merged_value, value) + merged_plist[key] = merged_value + else: + merged_plist[key] = value + else: + merged_plist[key] = value + + def _LoadPlistMaybeBinary(self, plist_path): + """Loads into a memory a plist possibly encoded in binary format. + + This is a wrapper around plistlib.readPlist that tries to convert the + plist to the XML format if it can't be parsed (assuming that it is in + the binary format). + + Args: + plist_path: string, path to a plist file, in XML or binary format + + Returns: + Content of the plist as a dictionary. + """ + try: + # First, try to read the file using plistlib that only supports XML, + # and if an exception is raised, convert a temporary copy to XML and + # load that copy. + return plistlib.readPlist(plist_path) + except Exception: + pass + with tempfile.NamedTemporaryFile() as temp: + shutil.copy2(plist_path, temp.name) + subprocess.check_call(["plutil", "-convert", "xml1", temp.name]) + return plistlib.readPlist(temp.name) + + def _GetSubstitutions(self, bundle_identifier, app_identifier_prefix): + """Constructs a dictionary of variable substitutions for Entitlements.plist. + + Args: + bundle_identifier: string, value of CFBundleIdentifier from Info.plist + app_identifier_prefix: string, value for AppIdentifierPrefix + + Returns: + Dictionary of substitutions to apply when generating Entitlements.plist. + """ + return { + "CFBundleIdentifier": bundle_identifier, + "AppIdentifierPrefix": app_identifier_prefix, + } + + def _GetCFBundleIdentifier(self): + """Extracts CFBundleIdentifier value from Info.plist in the bundle. + + Returns: + Value of CFBundleIdentifier in the Info.plist located in the bundle. + """ + info_plist_path = os.path.join( + os.environ["TARGET_BUILD_DIR"], os.environ["INFOPLIST_PATH"] + ) + info_plist_data = self._LoadPlistMaybeBinary(info_plist_path) + return info_plist_data["CFBundleIdentifier"] + + def _InstallEntitlements(self, entitlements, substitutions, overrides): + """Generates and install the ${BundleName}.xcent entitlements file. + + Expands variables "$(variable)" pattern in the source entitlements file, + add extra entitlements defined in the .mobileprovision file and the copy + the generated plist to "${BundlePath}.xcent". + + Args: + entitlements: string, optional, path to the Entitlements.plist template + to use, defaults to "${SDKROOT}/Entitlements.plist" + substitutions: dictionary, variable substitutions + overrides: dictionary, values to add to the entitlements + + Returns: + Path to the generated entitlements file. + """ + source_path = entitlements + target_path = os.path.join( + os.environ["BUILT_PRODUCTS_DIR"], os.environ["PRODUCT_NAME"] + ".xcent" + ) + if not source_path: + source_path = os.path.join(os.environ["SDKROOT"], "Entitlements.plist") + shutil.copy2(source_path, target_path) + data = self._LoadPlistMaybeBinary(target_path) + data = self._ExpandVariables(data, substitutions) + if overrides: + for key in overrides: + if key not in data: + data[key] = overrides[key] + plistlib.writePlist(data, target_path) + return target_path + + def _ExpandVariables(self, data, substitutions): + """Expands variables "$(variable)" in data. + + Args: + data: object, can be either string, list or dictionary + substitutions: dictionary, variable substitutions to perform + + Returns: + Copy of data where each references to "$(variable)" has been replaced + by the corresponding value found in substitutions, or left intact if + the key was not found. + """ + if isinstance(data, str): + for key, value in substitutions.items(): + data = data.replace("$(%s)" % key, value) + return data + if isinstance(data, list): + return [self._ExpandVariables(v, substitutions) for v in data] + if isinstance(data, dict): + return {k: self._ExpandVariables(data[k], substitutions) for k in data} + return data + + +def NextGreaterPowerOf2(x): + return 2 ** (x).bit_length() + + +def WriteHmap(output_name, filelist): + """Generates a header map based on |filelist|. + + Per Mark Mentovai: + A header map is structured essentially as a hash table, keyed by names used + in #includes, and providing pathnames to the actual files. + + The implementation below and the comment above comes from inspecting: + http://www.opensource.apple.com/source/distcc/distcc-2503/distcc_dist/include_server/headermap.py?txt + while also looking at the implementation in clang in: + https://llvm.org/svn/llvm-project/cfe/trunk/lib/Lex/HeaderMap.cpp + """ + magic = 1751998832 + version = 1 + _reserved = 0 + count = len(filelist) + capacity = NextGreaterPowerOf2(count) + strings_offset = 24 + (12 * capacity) + max_value_length = max(len(value) for value in filelist.values()) + + out = open(output_name, "wb") + out.write( + struct.pack( + "=12" + } + }, + "node_modules/@npmcli/agent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", + "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", + "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", + "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^4.0.0", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-fetch-happen": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", + "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^2.0.0", + "cacache": "^18.0.0", + "http-cache-semantics": "^4.1.1", + "is-lambda": "^1.0.1", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz", + "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-gyp": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.3.1.tgz", + "integrity": "sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^13.0.0", + "nopt": "^7.0.0", + "proc-log": "^4.1.0", + "semver": "^7.3.5", + "tar": "^6.2.1", + "which": "^4.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ssri": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", + "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/unique-filename": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", + "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/unique-slug": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", + "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/vendor/printenvz/package.json b/vendor/printenvz/package.json new file mode 100644 index 00000000000..440c40a5ed8 --- /dev/null +++ b/vendor/printenvz/package.json @@ -0,0 +1,23 @@ +{ + "name": "printenvz", + "version": "1.0.0", + "description": "A native module that prints environment variables to stdout", + "main": "index.js", + "types": "index.d.ts", + "scripts": { + "install": "node-gyp rebuild", + "build": "node-gyp build", + "clean": "node-gyp clean", + "rebuild": "node-gyp rebuild" + }, + "gypfile": true, + "dependencies": { + "node-gyp": "^10.0.1" + }, + "devDependencies": { + "node-gyp": "^10.0.1" + }, + "keywords": ["native", "environment", "variables", "executable"], + "author": "", + "license": "MIT" +} diff --git a/vendor/printenvz/src/printenvz.cc b/vendor/printenvz/src/printenvz.cc new file mode 100644 index 00000000000..8521eb5de17 --- /dev/null +++ b/vendor/printenvz/src/printenvz.cc @@ -0,0 +1,15 @@ +#include +#include +#include + +extern char **environ; + +int main() { + // Iterate through all environment variables + for (char **env = environ; *env != nullptr; ++env) { + // Print the environment variable followed by null terminator + std::cout << *env << '\0'; + } + + return 0; +} From 163a15845dd8483f97dd317fd9a4f316a907f334 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 14:00:59 +0100 Subject: [PATCH 105/865] Get clean shell env --- app/package.json | 1 + app/src/lib/hooks/hooks-proxy.ts | 46 +++- app/src/lib/hooks/with-hooks-env.ts | 23 +- app/yarn.lock | 2 +- package.json | 2 + script/build.ts | 35 ++- yarn.lock | 366 +++++++++++++++++++++++++++- 7 files changed, 444 insertions(+), 31 deletions(-) diff --git a/app/package.json b/app/package.json index 6902684b531..a61e952a7dc 100644 --- a/app/package.json +++ b/app/package.json @@ -69,6 +69,7 @@ "tslib": "^2.0.0", "untildify": "^3.0.2", "uuid": "^3.0.1", + "which": "^5.0.0", "windows-argv-parser": "file:../vendor/windows-argv-parser", "winston": "^3.6.0" }, diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index ecad412176c..f5a2658b33c 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -7,6 +7,7 @@ import { ProcessProxyConnection } from 'process-proxy' import { Shescape } from 'shescape' import { Writable } from 'stream' import { pipeline } from 'stream/promises' +import { execFile } from '../exec-file' const hooksUsingStdin = ['post-rewrite'] @@ -76,7 +77,35 @@ const exitWithError = ( }) } -export const createHooksProxy = (repoHooks: string[], tmpDir: string) => { +const getCleanShellEnv = async (): Promise> => { + const ext = __WIN32__ ? '.exe' : '' + const printenvzPath = join(__dirname, `printenvz${ext}`) + + const { shell, args, quote } = getShell() + const { stdout } = await execFile(shell, [...args, quote(printenvzPath)], { + env: {}, + }) + + return Object.fromEntries( + stdout.split('\0').map(line => { + const eqIndex = line.indexOf('=') + if (eqIndex === -1) { + throw new Error(`Invalid env var line: ${line}`) + } + const key = line.substring(0, eqIndex) + const value = line.substring(eqIndex + 1) + return [key, value] + }) + ) +} + +export const createHooksProxy = ( + repoHooks: string[], + tmpDir: string, + gitPath: string +) => { + const cleanShellEnv = memoizeOne(getCleanShellEnv) + return async (connection: ProcessProxyConnection) => { const startTime = Date.now() const proxyArgs = await connection.getArgs() @@ -129,10 +158,7 @@ export const createHooksProxy = (repoHooks: string[], tmpDir: string) => { await pipeline(connection.stdin, createWriteStream(stdinFilePath)) } - const { shell, args: shellArgs, quote } = getShell() - - const cmdArgs = [ - 'git', + const args = [ 'hook', 'run', hookName, @@ -140,7 +166,7 @@ export const createHooksProxy = (repoHooks: string[], tmpDir: string) => { '--', ...proxyArgs.slice(1), ] - const cmd = cmdArgs.map(quote).join(' ') + const shellEnv = await cleanShellEnv() const { code } = await new Promise<{ code: number | null @@ -149,15 +175,15 @@ export const createHooksProxy = (repoHooks: string[], tmpDir: string) => { const abortController = new AbortController() connection.on('close', () => abortController.abort()) - const child = spawn(shell, [...shellArgs, cmd], { + const child = spawn(gitPath, args, { cwd: proxyCwd, - env: safeEnv, + env: { ...shellEnv, ...safeEnv }, signal: abortController.signal, }) - .on('error', reject) .on('close', (code, signal) => resolve({ code, signal })) + .on('error', reject) - // Git hooks only write to stderr + // hooks never write to stdout // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 child.stderr.pipe(connection.stderr).on('error', reject) }) diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index efd494c68d5..25a8a3d7ec7 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -1,11 +1,12 @@ -import { tmpdir } from 'os' -import { enableHooksEnvironment } from '../feature-flag' -import { getRepoHooks } from './get-repo-hooks' import { cp, mkdtemp, rm } from 'fs/promises' -import { join, basename } from 'path' -import { createProxyProcessServer } from 'process-proxy' import { AddressInfo } from 'net' +import { tmpdir } from 'os' +import { basename, join } from 'path' +import { createProxyProcessServer } from 'process-proxy' +import which from 'which' +import { enableHooksEnvironment } from '../feature-flag' import type { IGitExecutionOptions } from '../git/core' +import { getRepoHooks } from './get-repo-hooks' import { createHooksProxy } from './hooks-proxy' export async function withHooksEnv( @@ -30,15 +31,22 @@ export async function withHooksEnv( return fn(options?.env) } + const gitPath = await which('git', { nothrow: true }) + + if (gitPath === null) { + log.info(`Git executable not found in PATH, skipping hook interception.`) + return fn(options?.env) + } + const ext = __WIN32__ ? '.exe' : '' const processProxyPath = join(__dirname, `process-proxy${ext}`) const token = crypto.randomUUID() const tmpHooksDir = await mkdtemp(join(tmpdir(), 'desktop-git-hooks-')) - const hooksProxy = createHooksProxy(repoHooks, tmpHooksDir) + const hooksProxy = createHooksProxy(repoHooks, tmpHooksDir, gitPath) const server = createProxyProcessServer( - conn => hooksProxy(conn).catch(e => conn.exit(1).catch(() => {})), + conn => hooksProxy(conn).catch(() => conn.exit(1).catch(() => {})), { validateConnection: async receivedToken => receivedToken === token } ) const port = await new Promise((resolve, reject) => { @@ -71,6 +79,7 @@ export async function withHooksEnv( PROCESS_PROXY_TOKEN: token, }) } finally { + server.close() // Clean up the temporary directory await rm(tmpHooksDir, { recursive: true, force: true }).catch(() => { // Ignore errors diff --git a/app/yarn.lock b/app/yarn.lock index 9745139aaac..80a27df2997 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -1478,7 +1478,7 @@ which@^1.2.9: dependencies: isexe "^2.0.0" -"which@^3.0.0 || ^4.0.0 || ^5.0.0": +"which@^3.0.0 || ^4.0.0 || ^5.0.0", which@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/which/-/which-5.0.0.tgz#d93f2d93f79834d4363c7d0c23e00d07c466c8d6" integrity sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ== diff --git a/package.json b/package.json index aea56a105c2..09d243c8e6f 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@primer/octicons": "^19.0.0", "@types/diff": "^7.0.2", + "@types/which": "^3.0.4", "@typescript-eslint/eslint-plugin": "^6.13.2", "@typescript-eslint/experimental-utils": "^5.62.0", "@typescript-eslint/parser": "^6.13.2", @@ -86,6 +87,7 @@ "parallel-webpack": "^2.6.0", "parse-dds": "^1.2.1", "prettier": "^2.6.0", + "printenvz": "file:./vendor/printenvz", "process-proxy": "^0.3.0", "rimraf": "^6.0.1", "sass": "^1.27.0", diff --git a/script/build.ts b/script/build.ts index 42af025260f..5200852787b 100755 --- a/script/build.ts +++ b/script/build.ts @@ -1,13 +1,14 @@ /* eslint-disable no-sync */ /// -import * as path from 'path' import * as cp from 'child_process' -import * as os from 'os' import packager, { OfficialArch, OsxNotarizeOptions } from 'electron-packager' import frontMatter from 'front-matter' -import { externals } from '../app/webpack.common' +import * as os from 'os' +import * as path from 'path' +import { getPrintenvzPath } from 'printenvz' import { getProxyCommandPath } from 'process-proxy' +import { externals } from '../app/webpack.common' interface IChooseALicense { readonly title: string @@ -29,18 +30,16 @@ import { getProductName, } from '../app/package-info' +import { isGitHubActions } from './build-platforms' import { getChannel, + getDistArchitecture, getDistRoot, getExecutableName, - isPublishable, getIconFileName, - getDistArchitecture, + isPublishable, } from './dist-info' -import { isGitHubActions } from './build-platforms' -import { updateLicenseDump } from './licenses/update-license-dump' -import { verifyInjectedSassVariables } from './validate-sass/validate-all' import { existsSync, mkdirSync, @@ -51,6 +50,8 @@ import { writeFileSync, } from 'fs' import { copySync } from 'fs-extra' +import { updateLicenseDump } from './licenses/update-license-dump' +import { verifyInjectedSassVariables } from './validate-sass/validate-all' const isPublishableBuild = isPublishable() const isDevelopmentBuild = getChannel() === 'development' @@ -374,6 +375,24 @@ function copyDependencies() { process.platform === 'win32' ? 'process-proxy.exe' : 'process-proxy' ) ) + + console.log(' Copying process-proxy binary') + copySync( + getProxyCommandPath(), + path.resolve( + outRoot, + process.platform === 'win32' ? 'process-proxy.exe' : 'process-proxy' + ) + ) + + console.log(' Copying printenvz binary') + copySync( + getPrintenvzPath(), + path.resolve( + outRoot, + process.platform === 'win32' ? 'printenvz.exe' : 'printenvz' + ) + ) } function generateLicenseMetadata(outRoot: string) { diff --git a/yarn.lock b/yarn.lock index 12c3efb7bca..0f51d9579f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -811,6 +811,24 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@npmcli/agent@^2.0.0": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@npmcli/agent/-/agent-2.2.2.tgz#967604918e62f620a648c7975461c9c9e74fc5d5" + integrity sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og== + dependencies: + agent-base "^7.1.0" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.1" + lru-cache "^10.0.1" + socks-proxy-agent "^8.0.3" + +"@npmcli/fs@^3.1.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-3.1.1.tgz#59cdaa5adca95d135fc00f2bb53f5771575ce726" + integrity sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg== + dependencies: + semver "^7.3.5" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -1319,6 +1337,11 @@ tapable "^2.2.0" webpack "^5" +"@types/which@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/which/-/which-3.0.4.tgz#2c3a89be70c56a84a6957a7264639f39ae4340a1" + integrity sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w== + "@types/yauzl@^2.9.1": version "2.9.1" resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.9.1.tgz#d10f69f9f522eef3cf98e30afb684a1e1ec923af" @@ -1683,6 +1706,11 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== + acorn-import-attributes@^1.9.5: version "1.9.5" resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" @@ -1727,6 +1755,14 @@ agent-base@^7.1.2: resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.3.tgz#29435eb821bc4194633a5b89e5bc4703bafc25a1" integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== +aggregate-error@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -2124,6 +2160,24 @@ builtin-modules@^1.0.0: resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" integrity sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8= +cacache@^18.0.0: + version "18.0.4" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-18.0.4.tgz#4601d7578dadb59c66044e157d02a3314682d6a5" + integrity sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ== + dependencies: + "@npmcli/fs" "^3.1.0" + fs-minipass "^3.0.0" + glob "^10.2.2" + lru-cache "^10.0.1" + minipass "^7.0.3" + minipass-collect "^2.0.1" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + p-map "^4.0.0" + ssri "^10.0.0" + tar "^6.1.11" + unique-filename "^3.0.0" + cacheable-lookup@^5.0.3: version "5.0.4" resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" @@ -2241,6 +2295,11 @@ chalk@^5.3.0: optionalDependencies: fsevents "~2.1.2" +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + chrome-trace-event@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b" @@ -2258,6 +2317,11 @@ clean-css@^5.2.2: dependencies: source-map "~0.6.0" +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" @@ -2754,6 +2818,13 @@ emojis-list@^3.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== +encoding@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -2797,6 +2868,11 @@ env-paths@^2.2.0: resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43" integrity sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA== +err-code@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" + integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== + errno@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d" @@ -3403,6 +3479,11 @@ events@^3.0.0, events@^3.2.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +exponential-backoff@^3.1.1: + version "3.1.3" + resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6" + integrity sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA== + extract-zip@^2.0.0, extract-zip@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" @@ -3631,6 +3712,20 @@ fs-extra@^9.0.1: jsonfile "^6.0.1" universalify "^1.0.0" +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + +fs-minipass@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-3.0.3.tgz#79a85981c4dc120065e96f62086bf6f9dc26cc54" + integrity sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw== + dependencies: + minipass "^7.0.3" + fs-monkey@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.5.tgz#fe450175f0db0d7ea758102e1d84096acb925788" @@ -3804,6 +3899,18 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== +glob@^10.2.2, glob@^10.3.10: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + glob@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.0.tgz#6031df0d7b65eaa1ccb9b29b5ced16cea658e77e" @@ -3962,7 +4069,7 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.6, graceful-fs@^4.2.0: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== -graceful-fs@^4.1.2, graceful-fs@^4.2.11, graceful-fs@^4.2.4: +graceful-fs@^4.1.2, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -4144,6 +4251,11 @@ http-cache-semantics@^4.0.0: resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== +http-cache-semantics@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5" + integrity sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ== + http-proxy-agent@^7.0.0, http-proxy-agent@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" @@ -4168,7 +4280,7 @@ https-proxy-agent@^7.0.0: agent-base "^7.0.2" debug "4" -https-proxy-agent@^7.0.6: +https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== @@ -4176,7 +4288,7 @@ https-proxy-agent@^7.0.6: agent-base "^7.1.2" debug "4" -iconv-lite@0.6.3: +iconv-lite@0.6.3, iconv-lite@^0.6.2: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -4216,6 +4328,11 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -4257,6 +4374,11 @@ interpret@^1.0.1: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.4.tgz#820cdd588b868ffb191a809506d6c9c8f212b1b0" integrity sha1-ggzdWIuGj/sZGoCVBtbJyPISsbA= +ip-address@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.0.1.tgz#a8180b783ce7788777d796286d61bce4276818ed" + integrity sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA== + is-array-buffer@^3.0.1, is-array-buffer@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe" @@ -4389,6 +4511,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-lambda@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" + integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ== + is-map@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" @@ -4528,6 +4655,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +isexe@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d" + integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ== + isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" @@ -4544,6 +4676,15 @@ iterator.prototype@^1.1.2: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + jackspeak@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.0.1.tgz#9fca4ce961af6083e259c376e9e3541431f5287b" @@ -4936,7 +5077,7 @@ lowercase-keys@^2.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== -lru-cache@^10.4.3: +lru-cache@^10.0.1, lru-cache@^10.2.0, lru-cache@^10.4.3: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== @@ -4965,6 +5106,24 @@ make-error@^1.1.1: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.0.tgz#52ad3a339ccf10ce62b4040b708fe707244b8b96" integrity sha1-Uq06M5zPEM5itAQLcI/nByRLi5Y= +make-fetch-happen@^13.0.0: + version "13.0.1" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz#273ba2f78f45e1f3a6dca91cede87d9fa4821e36" + integrity sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA== + dependencies: + "@npmcli/agent" "^2.0.0" + cacache "^18.0.0" + http-cache-semantics "^4.1.1" + is-lambda "^1.0.1" + minipass "^7.0.2" + minipass-fetch "^3.0.0" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + negotiator "^0.6.3" + proc-log "^4.2.0" + promise-retry "^2.0.1" + ssri "^10.0.0" + markdown-it@13.0.1: version "13.0.1" resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-13.0.1.tgz#c6ecc431cacf1a5da531423fc6a42807814af430" @@ -5121,11 +5280,70 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== -minipass@^7.1.2: +minipass-collect@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-2.0.1.tgz#1621bc77e12258a12c60d34e2276ec5c20680863" + integrity sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw== + dependencies: + minipass "^7.0.3" + +minipass-fetch@^3.0.0: + version "3.0.5" + resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-3.0.5.tgz#f0f97e40580affc4a35cc4a1349f05ae36cb1e4c" + integrity sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg== + dependencies: + minipass "^7.0.3" + minipass-sized "^1.0.3" + minizlib "^2.1.2" + optionalDependencies: + encoding "^0.1.13" + +minipass-flush@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" + integrity sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw== + dependencies: + minipass "^3.0.0" + +minipass-pipeline@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" + integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== + dependencies: + minipass "^3.0.0" + +minipass-sized@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/minipass-sized/-/minipass-sized-1.0.3.tgz#70ee5a7c5052070afacfbc22977ea79def353b70" + integrity sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== + dependencies: + minipass "^3.0.0" + +minipass@^3.0.0: + version "3.3.6" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" + integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== + dependencies: + yallist "^4.0.0" + +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.2, minipass@^7.0.3, minipass@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== +minizlib@^2.1.1, minizlib@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + mkdirp@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" @@ -5133,6 +5351,11 @@ mkdirp@^0.5.1: dependencies: minimist "0.0.8" +mkdirp@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + mrmime@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.0.tgz#151082a6e06e59a9a39b46b3e14d5cfe92b3abb4" @@ -5163,6 +5386,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +negotiator@^0.6.3: + version "0.6.4" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7" + integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== + neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" @@ -5176,6 +5404,22 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-gyp@^10.0.1: + version "10.3.1" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-10.3.1.tgz#1dd1a1a1c6c5c59da1a76aea06a062786b2c8a1a" + integrity sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ== + dependencies: + env-paths "^2.2.0" + exponential-backoff "^3.1.1" + glob "^10.3.10" + graceful-fs "^4.2.6" + make-fetch-happen "^13.0.0" + nopt "^7.0.0" + proc-log "^4.1.0" + semver "^7.3.5" + tar "^6.2.1" + which "^4.0.0" + node-ipc@^9.1.0: version "9.1.3" resolved "https://registry.yarnpkg.com/node-ipc/-/node-ipc-9.1.3.tgz#1df3f069d103184ae9127fa885dbdaea56a4436f" @@ -5205,6 +5449,13 @@ node-test-parser@^2.2.1: resolved "https://registry.yarnpkg.com/node-test-parser/-/node-test-parser-2.2.2.tgz#486f0d0c08e31e6b341eadf6a9f574e8c53d8259" integrity sha512-Cbe0pabtJaZOrjvCguHe9kZLDrHZpRr+4+JO29hNf143qFUhGn6Xn5HxwQmh4vmyyLFlF2YmnJGIwfEX+aQ7mw== +nopt@^7.0.0: + version "7.2.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7" + integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w== + dependencies: + abbrev "^2.0.0" + normalize-package-data@^2.0.0, normalize-package-data@^2.3.2: version "2.4.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" @@ -5386,6 +5637,13 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-map@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" + integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== + dependencies: + aggregate-error "^3.0.0" + package-json-from-dist@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" @@ -5491,6 +5749,14 @@ path-parse@^1.0.5, path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-scurry@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.0.tgz#9f052289f23ad8bf9397a2a0425e7b8615c58580" @@ -5629,6 +5895,16 @@ pretty-error@^4.0.0: lodash "^4.17.20" renderkid "^3.0.0" +"printenvz@file:./vendor/printenvz": + version "1.0.0" + dependencies: + node-gyp "^10.0.1" + +proc-log@^4.1.0, proc-log@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-4.2.0.tgz#b6f461e4026e75fdfe228b265e9f7a00779d7034" + integrity sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA== + process-proxy@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/process-proxy/-/process-proxy-0.3.0.tgz#5bdb0cf430214868d395b3a9a5b74f12f519afbb" @@ -5639,6 +5915,14 @@ progress@^2.0.3: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +promise-retry@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22" + integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g== + dependencies: + err-code "^2.0.2" + retry "^0.12.0" + prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" @@ -5868,6 +6152,11 @@ responselike@^2.0.0: dependencies: lowercase-keys "^2.0.0" +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -6025,6 +6314,11 @@ semver@^7.1.3, semver@^7.3.2, semver@^7.3.4: dependencies: lru-cache "^6.0.0" +semver@^7.3.5: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + semver@^7.3.7, semver@^7.5.4: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" @@ -6132,6 +6426,28 @@ slide@~1.1.3: resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc= +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + +socks-proxy-agent@^8.0.3: + version "8.0.5" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee" + integrity sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw== + dependencies: + agent-base "^7.1.2" + debug "^4.3.4" + socks "^2.8.3" + +socks@^2.8.3: + version "2.8.7" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.7.tgz#e2fb1d9a603add75050a2067db8c381a0b5669ea" + integrity sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A== + dependencies: + ip-address "^10.0.1" + smart-buffer "^4.2.0" + source-map-js@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" @@ -6195,6 +6511,13 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= +ssri@^10.0.0: + version "10.0.6" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-10.0.6.tgz#a8aade2de60ba2bce8688e3fa349bad05c7dc1e5" + integrity sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ== + dependencies: + minipass "^7.0.3" + stack-utils@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" @@ -6426,6 +6749,18 @@ tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== +tar@^6.1.11, tar@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" + integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^5.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + temp@^0.9.0: version "0.9.1" resolved "https://registry.yarnpkg.com/temp/-/temp-0.9.1.tgz#2d666114fafa26966cd4065996d7ceedd4dd4697" @@ -6756,6 +7091,20 @@ undici@^5.25.4: dependencies: "@fastify/busboy" "^2.0.0" +unique-filename@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea" + integrity sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g== + dependencies: + unique-slug "^4.0.0" + +unique-slug@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-4.0.0.tgz#6bae6bb16be91351badd24cdce741f892a6532e3" + integrity sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ== + dependencies: + imurmurhash "^0.1.4" + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" @@ -7029,6 +7378,13 @@ which@^2.0.1, which@^2.0.2: dependencies: isexe "^2.0.0" +which@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/which/-/which-4.0.0.tgz#cd60b5e74503a3fbcfbf6cd6b4138a8bae644c1a" + integrity sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg== + dependencies: + isexe "^3.1.1" + wildcard@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" From 51e96d88e10a19b3b4e6201243ec638d103f16c4 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 16:10:02 +0100 Subject: [PATCH 106/865] Execute Git directly after having obtained a clean shell environment Extracted shell and environment logic into get-shell and get-shell-env modules. Updated hooks-proxy and with-hooks-env to use the new shellEnv, improving reliability and separation of concerns. Added support for passing shell environment to hook proxy and locating git executable using the correct PATH. --- app/src/lib/hooks/get-shell-env.ts | 18 +++++++ app/src/lib/hooks/get-shell.ts | 31 ++++++++++++ app/src/lib/hooks/hooks-proxy.ts | 74 ++++------------------------- app/src/lib/hooks/with-hooks-env.ts | 20 +++++--- 4 files changed, 72 insertions(+), 71 deletions(-) create mode 100644 app/src/lib/hooks/get-shell-env.ts create mode 100644 app/src/lib/hooks/get-shell.ts diff --git a/app/src/lib/hooks/get-shell-env.ts b/app/src/lib/hooks/get-shell-env.ts new file mode 100644 index 00000000000..14e85f09aa5 --- /dev/null +++ b/app/src/lib/hooks/get-shell-env.ts @@ -0,0 +1,18 @@ +import { join } from 'path' +import { getShell } from './get-shell' +import { execFile } from '../exec-file' + +export const getShellEnv = async (): Promise< + Record +> => { + const ext = __WIN32__ ? '.exe' : '' + const printenvzPath = join(__dirname, `printenvz${ext}`) + + const { shell, args, quote } = getShell() + const { stdout } = await execFile(shell, [...args, quote(printenvzPath)], { + env: {}, + }) + + const matches = stdout.matchAll(/([^=]+)=([^\0]*)\0/g) + return Object.fromEntries(Array.from(matches, m => [m[1], m[2]])) +} diff --git a/app/src/lib/hooks/get-shell.ts b/app/src/lib/hooks/get-shell.ts new file mode 100644 index 00000000000..ebf2ba3a872 --- /dev/null +++ b/app/src/lib/hooks/get-shell.ts @@ -0,0 +1,31 @@ +import memoizeOne from 'memoize-one' +import { Shescape } from 'shescape' + +const getQuoteFn = memoizeOne((shell: string) => { + const shescape = new Shescape({ shell, flagProtection: false }) + return { + escape: shescape.escape.bind(shescape), + quote: shescape.quote.bind(shescape), + } +}) + +export const getShell = () => { + // TODO: Windows: + if (__WIN32__) { + throw new Error('Not implemented') + } + + if (process.env.SHELL) { + return { + shell: process.env.SHELL, + args: ['-ilc'], + ...getQuoteFn(process.env.SHELL), + } + } + + return { + shell: '/bin/sh', + args: ['-ilc'], + ...getQuoteFn('/bin/sh'), + } +} diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index f5a2658b33c..2c7eb8a0249 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -1,13 +1,10 @@ import { spawn } from 'child_process' import { randomBytes } from 'crypto' import { createWriteStream } from 'fs' -import memoizeOne from 'memoize-one' import { basename, join } from 'path' import { ProcessProxyConnection } from 'process-proxy' -import { Shescape } from 'shescape' import { Writable } from 'stream' import { pipeline } from 'stream/promises' -import { execFile } from '../exec-file' const hooksUsingStdin = ['post-rewrite'] @@ -15,39 +12,6 @@ const debug = (message: string, error?: Error) => { log.debug(`hooks: ${message}`, error) } -const getShell = () => { - // TODO: Windows: - if (__WIN32__) { - throw new Error('Not implemented') - } - - if (process.env.SHELL) { - try { - return { - shell: process.env.SHELL, - args: ['-ilc'], - ...getQuoteFn(process.env.SHELL), - } - } catch (err) { - debug('Failed resolving shell', err) - } - } - - return { - shell: '/bin/sh', - args: ['-ilc'], - ...getQuoteFn('/bin/sh'), - } -} - -const getQuoteFn = memoizeOne((shell: string) => { - const shescape = new Shescape({ shell, flagProtection: false }) - return { - escape: shescape.escape.bind(shescape), - quote: shescape.quote.bind(shescape), - } -}) - const waitForWritableFinished = (stream: Writable) => { return new Promise(resolve => { if (stream.writableFinished) { @@ -77,35 +41,12 @@ const exitWithError = ( }) } -const getCleanShellEnv = async (): Promise> => { - const ext = __WIN32__ ? '.exe' : '' - const printenvzPath = join(__dirname, `printenvz${ext}`) - - const { shell, args, quote } = getShell() - const { stdout } = await execFile(shell, [...args, quote(printenvzPath)], { - env: {}, - }) - - return Object.fromEntries( - stdout.split('\0').map(line => { - const eqIndex = line.indexOf('=') - if (eqIndex === -1) { - throw new Error(`Invalid env var line: ${line}`) - } - const key = line.substring(0, eqIndex) - const value = line.substring(eqIndex + 1) - return [key, value] - }) - ) -} - export const createHooksProxy = ( repoHooks: string[], tmpDir: string, - gitPath: string + gitPath: string, + shellEnv: Record ) => { - const cleanShellEnv = memoizeOne(getCleanShellEnv) - return async (connection: ProcessProxyConnection) => { const startTime = Date.now() const proxyArgs = await connection.getArgs() @@ -127,6 +68,10 @@ export const createHooksProxy = ( // user-configured but since we're executing the hook in a separate // shell with login it would just get re-initialized there anyway. 'GIT_CONFIG_PARAMETERS', + + 'GIT_ASKPASS', + 'GIT_SSH_COMMAND', + 'GIT_USER_AGENT', ]) const safeEnv = Object.fromEntries( @@ -166,8 +111,6 @@ export const createHooksProxy = ( '--', ...proxyArgs.slice(1), ] - const shellEnv = await cleanShellEnv() - const { code } = await new Promise<{ code: number | null signal: NodeJS.Signals | null @@ -181,7 +124,10 @@ export const createHooksProxy = ( signal: abortController.signal, }) .on('close', (code, signal) => resolve({ code, signal })) - .on('error', reject) + .on('error', err => { + debug(`failed to spawn hook process:`, err) + reject(err) + }) // hooks never write to stdout // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index 25a8a3d7ec7..bcb8c8d4ee5 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -8,6 +8,7 @@ import { enableHooksEnvironment } from '../feature-flag' import type { IGitExecutionOptions } from '../git/core' import { getRepoHooks } from './get-repo-hooks' import { createHooksProxy } from './hooks-proxy' +import { getShellEnv } from './get-shell-env' export async function withHooksEnv( fn: (env: Record | undefined) => Promise, @@ -31,22 +32,27 @@ export async function withHooksEnv( return fn(options?.env) } - const gitPath = await which('git', { nothrow: true }) + const shellEnv = await getShellEnv() - if (gitPath === null) { - log.info(`Git executable not found in PATH, skipping hook interception.`) - return fn(options?.env) - } + // TODO: will throw + const gitPath = await which('git', { + path: shellEnv.PATH, + pathExt: shellEnv.PATHEXT, + }) const ext = __WIN32__ ? '.exe' : '' const processProxyPath = join(__dirname, `process-proxy${ext}`) const token = crypto.randomUUID() const tmpHooksDir = await mkdtemp(join(tmpdir(), 'desktop-git-hooks-')) - const hooksProxy = createHooksProxy(repoHooks, tmpHooksDir, gitPath) + const hooksProxy = createHooksProxy(repoHooks, tmpHooksDir, gitPath, shellEnv) const server = createProxyProcessServer( - conn => hooksProxy(conn).catch(() => conn.exit(1).catch(() => {})), + conn => + hooksProxy(conn).catch(err => { + log.error(`hooks proxy failed:`, err) + conn.exit(1).catch(() => {}) + }), { validateConnection: async receivedToken => receivedToken === token } ) const port = await new Promise((resolve, reject) => { From 948f5cbad0fc6192b31ea91d2785259558f750bf Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 16:26:50 +0100 Subject: [PATCH 107/865] Use hooks subcommand from embedded Git --- app/src/lib/hooks/with-hooks-env.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index bcb8c8d4ee5..11fe62a51b1 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -1,14 +1,14 @@ import { cp, mkdtemp, rm } from 'fs/promises' import { AddressInfo } from 'net' import { tmpdir } from 'os' -import { basename, join } from 'path' +import { basename, join, resolve } from 'path' import { createProxyProcessServer } from 'process-proxy' -import which from 'which' import { enableHooksEnvironment } from '../feature-flag' import type { IGitExecutionOptions } from '../git/core' import { getRepoHooks } from './get-repo-hooks' import { createHooksProxy } from './hooks-proxy' import { getShellEnv } from './get-shell-env' +import { resolveGitBinary } from 'dugite' export async function withHooksEnv( fn: (env: Record | undefined) => Promise, @@ -32,13 +32,16 @@ export async function withHooksEnv( return fn(options?.env) } + const shellEnvStartTime = Date.now() const shellEnv = await getShellEnv() + log.debug( + `hooks: loaded shell environment in ${Date.now() - shellEnvStartTime}ms` + ) // TODO: will throw - const gitPath = await which('git', { - path: shellEnv.PATH, - pathExt: shellEnv.PATHEXT, - }) + const gitPathStartTime = Date.now() + const gitPath = resolveGitBinary(resolve(__dirname, 'git')) + log.debug(`hooks: located git in ${Date.now() - gitPathStartTime}ms`) const ext = __WIN32__ ? '.exe' : '' const processProxyPath = join(__dirname, `process-proxy${ext}`) From c6e667f29bdc4880a70f06b122fc2a0528bc1d78 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 16:26:56 +0100 Subject: [PATCH 108/865] Update get-shell-env.ts --- app/src/lib/hooks/get-shell-env.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/lib/hooks/get-shell-env.ts b/app/src/lib/hooks/get-shell-env.ts index 14e85f09aa5..90f19d4c558 100644 --- a/app/src/lib/hooks/get-shell-env.ts +++ b/app/src/lib/hooks/get-shell-env.ts @@ -11,6 +11,7 @@ export const getShellEnv = async (): Promise< const { shell, args, quote } = getShell() const { stdout } = await execFile(shell, [...args, quote(printenvzPath)], { env: {}, + maxBuffer: Infinity, }) const matches = stdout.matchAll(/([^=]+)=([^\0]*)\0/g) From 3748b280e878e63fe1372b8690bb187100b3692a Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 5 Nov 2025 16:40:58 +0100 Subject: [PATCH 109/865] Filter and validate known Git hooks in getRepoHooks Introduces a list of known Git hook names and updates getRepoHooks to filter out unknown hooks, .sample files, and handle Windows-specific executable checks. This improves accuracy and reliability when listing repository hooks. --- app/src/lib/hooks/get-repo-hooks.ts | 58 +++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/app/src/lib/hooks/get-repo-hooks.ts b/app/src/lib/hooks/get-repo-hooks.ts index f28dac0cb36..574726cd1a4 100644 --- a/app/src/lib/hooks/get-repo-hooks.ts +++ b/app/src/lib/hooks/get-repo-hooks.ts @@ -7,6 +7,37 @@ const isExecutable = (path: string) => .then(() => true) .catch(() => false) +const knownHooks = [ + 'applypatch-msg', + 'pre-applypatch', + 'post-applypatch', + 'pre-commit', + 'pre-merge-commit', + 'prepare-commit-msg', + 'commit-msg', + 'post-commit', + 'pre-rebase', + 'post-checkout', + 'post-merge', + 'pre-push', + 'pre-receive', + 'update', + 'proc-receive', + 'post-receive', + 'post-update', + 'reference-transaction', + 'push-to-checkout', + 'pre-auto-gc', + 'post-rewrite', + 'sendemail-validate', + 'fsmonitor-watchman', + 'p4-changelist', + 'p4-prepare-changelist', + 'p4-post-changelist', + 'p4-pre-submit', + 'post-index-change', +] + export async function* getRepoHooks(path: string, filter?: string[]) { // TODO: Could we cache this? For just a little while? // Probably not because we need to react to changes to core.hooksPath on the @@ -26,22 +57,33 @@ export async function* getRepoHooks(path: string, filter?: string[]) { .catch(() => []) for (const hook of files) { - if (filter && !filter.includes(hook.name)) { + const hookName = hook.name.endsWith('.exe') + ? hook.name.slice(0, -4) + : hook.name + + if (filter && !filter.includes(hookName)) { + continue + } + + if (!knownHooks.includes(hookName)) { + continue + } + + if (hookName.endsWith('.sample')) { continue } const hookPath = join(hook.parentPath, hook.name) if (__WIN32__) { - if (hook.name.endsWith('.exe')) { - continue - } + // On Windows we have to assume that any valid hook name is executable + // because the executable bit is not used there. Git looks for a shebang + // but that seems expensive to check here :shrug: + yield hookPath } else { - if (!(await isExecutable(hookPath))) { - continue + if (await isExecutable(hookPath)) { + yield hookPath } } - - yield hookPath } } From e6098b0228fd768e1e97a19f5ce5b382fe8218cf Mon Sep 17 00:00:00 2001 From: Leonardo Manrique Date: Wed, 5 Nov 2025 20:50:38 +0100 Subject: [PATCH 110/865] Add ARM64 Cursor editor detection Add support for detecting the ARM64 version of Cursor editor on Windows by including its registry key in the list of uninstall keys to check. --- app/src/lib/editors/win32.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/lib/editors/win32.ts b/app/src/lib/editors/win32.ts index 41d7e601bd6..733d1ed324a 100644 --- a/app/src/lib/editors/win32.ts +++ b/app/src/lib/editors/win32.ts @@ -482,6 +482,8 @@ const editors: WindowsExternalEditor[] = [ registryKeys: [ CurrentUserUninstallKey('62625861-8486-5be9-9e46-1da50df5f8ff'), CurrentUserUninstallKey('{DADADADA-ADAD-ADAD-ADAD-ADADADADADAD}}_is1'), + // ARM64 version of Cursor + CurrentUserUninstallKey('{DBDBDBDB-BDBD-BDBD-BDBD-BDBDBDBDBDBD}}_is1'), ], installLocationRegistryKey: 'DisplayIcon', displayNamePrefixes: ['Cursor', 'Cursor (User)'], From 1b05c925320f5f54c635c903be439738335de547 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 08:47:32 +0100 Subject: [PATCH 111/865] Eh, this is fast enough --- app/src/lib/hooks/get-repo-hooks.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/lib/hooks/get-repo-hooks.ts b/app/src/lib/hooks/get-repo-hooks.ts index 574726cd1a4..edde20cbfec 100644 --- a/app/src/lib/hooks/get-repo-hooks.ts +++ b/app/src/lib/hooks/get-repo-hooks.ts @@ -39,9 +39,6 @@ const knownHooks = [ ] export async function* getRepoHooks(path: string, filter?: string[]) { - // TODO: Could we cache this? For just a little while? - // Probably not because we need to react to changes to core.hooksPath on the - // fly but it sure would be nice. const { exitCode, stdout } = await exec( ['config', '-z', '--get', 'core.hooksPath'], path From 3631f231cb5883a411695fba1685c31bca5b8e2c Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 10:57:20 +0100 Subject: [PATCH 112/865] Add commit hook progress UI and state tracking Introduces HookProgress type and tracks hook execution status in repository state. Updates UI components to display commit hook progress and repository optimization status during commit operations. Enhances user feedback for commit hooks and git garbage collection events. --- app/src/lib/app-state.ts | 6 +- app/src/lib/git/commit.ts | 7 ++- app/src/lib/git/core.ts | 14 +++++ app/src/lib/hooks/hooks-proxy.ts | 41 +++++++++++-- app/src/lib/hooks/with-hooks-env.ts | 8 ++- app/src/lib/stores/app-store.ts | 22 ++++++- app/src/lib/stores/repository-state-cache.ts | 2 + app/src/ui/changes/changes-list.tsx | 7 +++ app/src/ui/changes/commit-message.tsx | 58 +++++++++++++++++++ app/src/ui/changes/filter-changes-list.tsx | 7 +++ app/src/ui/changes/sidebar.tsx | 5 ++ .../commit-message/commit-message-dialog.tsx | 3 + app/src/ui/repository.tsx | 2 + app/styles/ui/changes/_commit-message.scss | 12 ++++ 14 files changed, 185 insertions(+), 9 deletions(-) diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 1ea5a6d160a..200cf85fae2 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -44,7 +44,7 @@ import { MultiCommitOperationDetail, MultiCommitOperationStep, } from '../models/multi-commit-operation' -import { IChangesetData } from './git' +import type { HookProgress, IChangesetData } from './git' import { Popup } from '../models/popup' import { RepoRulesInfo } from '../models/repo-rules' import { IAPIRepoRuleset } from './api' @@ -549,6 +549,10 @@ export interface IRepositoryState { /** The date the repository was last fetched. */ readonly lastFetched: Date | null + readonly isRunningGitGC: boolean + + readonly hookProgress: HookProgress | null + /** * If we're currently working on switching to a new branch this * provides insight into the progress of that operation. diff --git a/app/src/lib/git/commit.ts b/app/src/lib/git/commit.ts index d84be370f2b..6298b38fffa 100644 --- a/app/src/lib/git/commit.ts +++ b/app/src/lib/git/commit.ts @@ -1,4 +1,4 @@ -import { git, parseCommitSHA } from './core' +import { git, HookProgress, parseCommitSHA } from './core' import { stageFiles } from './update-index' import { Repository } from '../../models/repository' import { WorkingDirectoryFileChange } from '../../models/status' @@ -16,7 +16,8 @@ export async function createCommit( repository: Repository, message: string, files: ReadonlyArray, - amend: boolean = false + amend: boolean = false, + onHookProgress?: (progress: HookProgress) => void ): Promise { // Clear the staging area, our diffs reflect the difference between the // working directory and the last commit (if any) so our commits should @@ -44,7 +45,9 @@ export async function createCommit( 'commit-msg', 'post-commit', 'post-rewrite', + 'pre-auto-gc', ], + onHookProgress, } ) return parseCommitSHA(result) diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 423b523a5e7..da3105690b8 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -37,6 +37,19 @@ export const isMaxBufferExceededError = ( ) } +export type HookProgress = + | { + readonly hookName: string + } & ( + | { + readonly status: 'started' + readonly abort: () => void + } + | { + readonly status: 'finished' | 'failed' + } + ) + /** * An extension of the execution options in dugite that * allows us to piggy-back our own configuration options in the @@ -66,6 +79,7 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { readonly isBackgroundTask?: boolean readonly interceptHooks?: boolean | string[] + readonly onHookProgress?: (progress: HookProgress) => void } /** diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 2c7eb8a0249..5faafbf10c7 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -5,6 +5,7 @@ import { basename, join } from 'path' import { ProcessProxyConnection } from 'process-proxy' import { Writable } from 'stream' import { pipeline } from 'stream/promises' +import type { HookProgress } from '../git' const hooksUsingStdin = ['post-rewrite'] @@ -45,7 +46,8 @@ export const createHooksProxy = ( repoHooks: string[], tmpDir: string, gitPath: string, - shellEnv: Record + shellEnv: Record, + onHookProgress?: (progress: HookProgress) => void ) => { return async (connection: ProcessProxyConnection) => { const startTime = Date.now() @@ -57,6 +59,14 @@ export const createHooksProxy = ( ? basename(proxyArgs[0]).replace(/\.exe$/i, '') : basename(proxyArgs[0]) + const abortController = new AbortController() + + onHookProgress?.({ + hookName, + status: 'started', + abort: () => abortController.abort(), + }) + const excludedEnvVars = new Set([ // Dugite sets these, we don't want to leak them into the hook environment 'GIT_SYSTEM_CONFIG', @@ -103,6 +113,12 @@ export const createHooksProxy = ( await pipeline(connection.stdin, createWriteStream(stdinFilePath)) } + if (abortController.signal.aborted) { + debug(`hook ${hookName} aborted before execution`) + await exitWithError(connection, `Hook ${hookName} aborted`) + return + } + const args = [ 'hook', 'run', @@ -111,11 +127,10 @@ export const createHooksProxy = ( '--', ...proxyArgs.slice(1), ] - const { code } = await new Promise<{ + const { code, signal } = await new Promise<{ code: number | null signal: NodeJS.Signals | null }>((resolve, reject) => { - const abortController = new AbortController() connection.on('close', () => abortController.abort()) const child = spawn(gitPath, args, { @@ -132,8 +147,18 @@ export const createHooksProxy = ( // hooks never write to stdout // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 child.stderr.pipe(connection.stderr).on('error', reject) + child.stderr.on('data', data => + console.log('hooks stderr:', data.toString()) + ) }) + if (signal !== null) { + debug(`hook ${hookName} was killed by signal ${signal}`) + connection.stderr.write( + `error: ${hookName} hook received signal ${signal}\n` + ) + } + await waitForWritableFinished(connection.stderr).catch(e => { debug(`waiting for stderr to finish failed`, e) }) @@ -142,8 +167,16 @@ export const createHooksProxy = ( debug( `executed ${hookName}: exited with code ${code} in ${elapsedSeconds}s` ) + + const exitCode = code ?? 1 await connection - .exit(code ?? 0) + .exit(exitCode) .catch(err => debug(`failed to exit proxy:`, err)) + .then(() => + onHookProgress?.({ + hookName, + status: exitCode === 0 ? 'finished' : 'failed', + }) + ) } } diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index 11fe62a51b1..4f293e5c629 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -48,7 +48,13 @@ export async function withHooksEnv( const token = crypto.randomUUID() const tmpHooksDir = await mkdtemp(join(tmpdir(), 'desktop-git-hooks-')) - const hooksProxy = createHooksProxy(repoHooks, tmpHooksDir, gitPath, shellEnv) + const hooksProxy = createHooksProxy( + repoHooks, + tmpHooksDir, + gitPath, + shellEnv, + options?.onHookProgress + ) const server = createProxyProcessServer( conn => diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 7180325c3fd..a3d56dab0bc 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -3309,7 +3309,23 @@ export class AppStore extends TypedBaseStore { return this.withIsCommitting(repository, async () => { const result = await gitStore.performFailableOperation(async () => { const message = await formatCommitMessage(repository, context) - return createCommit(repository, message, selectedFiles, context.amend) + return createCommit( + repository, + message, + selectedFiles, + context.amend, + hookProgress => { + this.repositoryStateCache.update(repository, state => ({ + ...state, + ...(hookProgress?.hookName === 'pre-auto-gc' && + hookProgress?.status === 'finished' + ? { isRunningGitGC: true } + : undefined), + hookProgress, + })) + this.emitUpdate() + } + ) }) if (result !== undefined) { @@ -4762,6 +4778,8 @@ export class AppStore extends TypedBaseStore { this.repositoryStateCache.update(repository, () => ({ isCommitting: true, + hookProgress: null, + isRunningGitGC: false, })) this.emitUpdate() @@ -4770,6 +4788,8 @@ export class AppStore extends TypedBaseStore { } finally { this.repositoryStateCache.update(repository, () => ({ isCommitting: false, + hookProgress: null, + isRunningGitGC: false, })) this.emitUpdate() } diff --git a/app/src/lib/stores/repository-state-cache.ts b/app/src/lib/stores/repository-state-cache.ts index bc2dd5e958f..4b95b448cab 100644 --- a/app/src/lib/stores/repository-state-cache.ts +++ b/app/src/lib/stores/repository-state-cache.ts @@ -363,6 +363,8 @@ function getInitialRepositoryState(): IRepositoryState { remote: null, isPushPullFetchInProgress: false, isCommitting: false, + isRunningGitGC: false, + hookProgress: null, isGeneratingCommitMessage: false, commitToAmend: null, lastFetched: null, diff --git a/app/src/ui/changes/changes-list.tsx b/app/src/ui/changes/changes-list.tsx index 0b849b30b46..65cea2cd167 100644 --- a/app/src/ui/changes/changes-list.tsx +++ b/app/src/ui/changes/changes-list.tsx @@ -59,6 +59,7 @@ import { RepoRulesInfo } from '../../models/repo-rules' import { IAheadBehind } from '../../models/branch' import { StashDiffViewerId } from '../stashing' import { enableFilteredChangesList } from '../../lib/feature-flag' +import { HookProgress } from '../../lib/git' const RowHeight = 29 const StashIcon: OcticonSymbolVariant = { @@ -174,6 +175,8 @@ interface IChangesListProps { readonly dispatcher: Dispatcher readonly availableWidth: number readonly isCommitting: boolean + readonly isRunningGitGC: boolean + readonly hookProgress: HookProgress | null readonly isGeneratingCommitMessage: boolean readonly shouldShowGenerateCommitMessageCallOut: boolean readonly commitToAmend: Commit | null @@ -768,6 +771,8 @@ export class ChangesList extends React.Component< repositoryAccount, dispatcher, isCommitting, + isRunningGitGC, + hookProgress, isGeneratingCommitMessage, commitToAmend, currentBranchProtected, @@ -839,6 +844,8 @@ export class ChangesList extends React.Component< focusCommitMessage={this.props.focusCommitMessage} autocompletionProviders={this.props.autocompletionProviders} isCommitting={isCommitting} + isRunningGitGC={isRunningGitGC} + hookProgress={hookProgress} isGeneratingCommitMessage={isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ shouldShowGenerateCommitMessageCallOut diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index a3fd8e69050..0951cca1456 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -64,6 +64,8 @@ import { isDotCom } from '../../lib/endpoint-capabilities' import { WorkingDirectoryFileChange } from '../../models/status' import { enableCommitMessageGeneration } from '../../lib/feature-flag' import { AriaLiveContainer } from '../accessibility/aria-live-container' +import { HookProgress } from '../../lib/git' +import { assertNever } from '../../lib/fatal-error' const addAuthorIcon: OcticonSymbolVariant = { w: 18, @@ -107,6 +109,8 @@ interface ICommitMessageProps { readonly repositoryAccount: Account | null readonly autocompletionProviders: ReadonlyArray> readonly isCommitting?: boolean + readonly isRunningGitGC: boolean + readonly hookProgress: HookProgress | null readonly isGeneratingCommitMessage?: boolean readonly shouldShowGenerateCommitMessageCallOut?: boolean readonly commitToAmend: Commit | null @@ -1546,6 +1550,59 @@ export class CommitMessage extends React.Component< ) } + private renderCommitProgress() { + const { isCommitting, isRunningGitGC, hookProgress } = this.props + if (!isCommitting) { + // return ( + //

+ // + // pre-commit hook finished + //
+ // ) + return null + } + + if (isRunningGitGC) { + return ( +
+ + Optimizing repository… +
+ ) + } + + if (!hookProgress) { + return null + } + + const { status, hookName } = hookProgress + + // const icon = + // status === 'started' ? ( + // + // ) : status === 'finished' ? ( + // + // ) : status === 'failed' ? ( + // + // ) : null + + const text = + status === 'started' + ? `${hookName} hook running…` + : status === 'finished' + ? `${hookName} hook finished` + : status === 'failed' + ? `${hookName} hook failed` + : assertNever(status, `Unknown hook status: ${status}`) + + return ( +
+ + {text} +
+ ) + } + public render() { const className = classNames('commit-message-component', { 'with-action-bar': this.isActionBarEnabled, @@ -1660,6 +1717,7 @@ export class CommitMessage extends React.Component< {this.renderBranchProtectionsRepoRulesCommitWarning()} {this.renderSubmitButton()} + {this.renderCommitProgress()} {this.state.isCommittingStatusMessage} diff --git a/app/src/ui/changes/filter-changes-list.tsx b/app/src/ui/changes/filter-changes-list.tsx index 15b2c72dc7e..e9de1782d88 100644 --- a/app/src/ui/changes/filter-changes-list.tsx +++ b/app/src/ui/changes/filter-changes-list.tsx @@ -73,6 +73,7 @@ import { applyFilters, } from './filter-changes-logic' import { ChangesListFilterOptions } from './changes-list-filter-options' +import { HookProgress } from '../../lib/git' export interface IChangesListItem extends IFilterListItem { readonly id: string @@ -157,6 +158,8 @@ interface IFilterChangesListProps { readonly dispatcher: Dispatcher readonly availableWidth: number readonly isCommitting: boolean + readonly isRunningGitGC: boolean + readonly hookProgress: HookProgress | null readonly isGeneratingCommitMessage: boolean readonly shouldShowGenerateCommitMessageCallOut: boolean readonly commitToAmend: Commit | null @@ -865,6 +868,8 @@ export class FilterChangesList extends React.Component< repositoryAccount, dispatcher, isCommitting, + isRunningGitGC, + hookProgress, isGeneratingCommitMessage, commitToAmend, currentBranchProtected, @@ -941,6 +946,8 @@ export class FilterChangesList extends React.Component< focusCommitMessage={this.props.focusCommitMessage} autocompletionProviders={this.props.autocompletionProviders} isCommitting={isCommitting} + isRunningGitGC={isRunningGitGC} + hookProgress={hookProgress} isGeneratingCommitMessage={isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ shouldShowGenerateCommitMessageCallOut diff --git a/app/src/ui/changes/sidebar.tsx b/app/src/ui/changes/sidebar.tsx index a5510108fbf..1a039f49649 100644 --- a/app/src/ui/changes/sidebar.tsx +++ b/app/src/ui/changes/sidebar.tsx @@ -33,6 +33,7 @@ import { IAheadBehind } from '../../models/branch' import { Emoji } from '../../lib/emoji' import { enableFilteredChangesList } from '../../lib/feature-flag' import { FilterChangesList } from './filter-changes-list' +import { HookProgress } from '../../lib/git' /** * The timeout for the animation of the enter/leave animation for Undo. @@ -56,6 +57,8 @@ interface IChangesSidebarProps { readonly issuesStore: IssuesStore readonly availableWidth: number readonly isCommitting: boolean + readonly isRunningGitGC: boolean + readonly hookProgress: HookProgress | null readonly isGeneratingCommitMessage: boolean readonly shouldShowGenerateCommitMessageCallOut: boolean readonly commitToAmend: Commit | null @@ -439,6 +442,8 @@ export class ChangesSidebar extends React.Component { onIgnoreFile={this.onIgnoreFile} onIgnorePattern={this.onIgnorePattern} isCommitting={this.props.isCommitting} + isRunningGitGC={this.props.isRunningGitGC} + hookProgress={this.props.hookProgress} isGeneratingCommitMessage={this.props.isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ this.props.shouldShowGenerateCommitMessageCallOut diff --git a/app/src/ui/commit-message/commit-message-dialog.tsx b/app/src/ui/commit-message/commit-message-dialog.tsx index 245161402ec..66308b249cf 100644 --- a/app/src/ui/commit-message/commit-message-dialog.tsx +++ b/app/src/ui/commit-message/commit-message-dialog.tsx @@ -164,6 +164,9 @@ export class CommitMessageDialog extends React.Component< onStopAmending={this.onStopAmending} onShowCreateForkDialog={this.onShowCreateForkDialog} accounts={this.props.accounts} + isCommitting={false} + isRunningGitGC={false} + hookProgress={null} /> diff --git a/app/src/ui/repository.tsx b/app/src/ui/repository.tsx index 135df802cce..e38662caee4 100644 --- a/app/src/ui/repository.tsx +++ b/app/src/ui/repository.tsx @@ -249,6 +249,8 @@ export class RepositoryView extends React.Component< availableWidth={availableWidth} gitHubUserStore={this.props.gitHubUserStore} isCommitting={this.props.state.isCommitting} + isRunningGitGC={this.props.state.isRunningGitGC} + hookProgress={this.props.state.hookProgress} isGeneratingCommitMessage={this.props.state.isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ this.props.shouldShowGenerateCommitMessageCallOut diff --git a/app/styles/ui/changes/_commit-message.scss b/app/styles/ui/changes/_commit-message.scss index d1d69997750..aec224953e1 100644 --- a/app/styles/ui/changes/_commit-message.scss +++ b/app/styles/ui/changes/_commit-message.scss @@ -12,6 +12,18 @@ padding: var(--spacing); + .commit-progress { + color: var(--text-secondary-color); + + display: flex; + align-items: center; + margin-top: var(--spacing-half); + svg.octicon { + margin-right: 3px; + height: 12px; + } + } + .copilot-disclaimer-popover-header { display: flex; From bff6f7492b780775c742900a81cf7e59d0c1cceb Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 11:29:29 +0100 Subject: [PATCH 113/865] Remove isRunningGitGC state and refactor commit progress UI Eliminates the isRunningGitGC property and related logic from repository state, store, and UI components. Commit progress rendering now relies solely on hookProgress, and the commit message progress UI is updated for improved clarity and styling. --- app/src/lib/app-state.ts | 2 - app/src/lib/stores/app-store.ts | 6 --- app/src/lib/stores/repository-state-cache.ts | 1 - app/src/ui/changes/changes-list.tsx | 3 -- app/src/ui/changes/commit-message.tsx | 47 ++++++------------- app/src/ui/changes/filter-changes-list.tsx | 3 -- app/src/ui/changes/sidebar.tsx | 2 - .../commit-message/commit-message-dialog.tsx | 1 - app/src/ui/repository.tsx | 1 - app/styles/ui/changes/_commit-message.scss | 36 +++++++++++++- 10 files changed, 49 insertions(+), 53 deletions(-) diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 200cf85fae2..c57ce7f0275 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -549,8 +549,6 @@ export interface IRepositoryState { /** The date the repository was last fetched. */ readonly lastFetched: Date | null - readonly isRunningGitGC: boolean - readonly hookProgress: HookProgress | null /** diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index a3d56dab0bc..d6ba24b6c29 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -3317,10 +3317,6 @@ export class AppStore extends TypedBaseStore { hookProgress => { this.repositoryStateCache.update(repository, state => ({ ...state, - ...(hookProgress?.hookName === 'pre-auto-gc' && - hookProgress?.status === 'finished' - ? { isRunningGitGC: true } - : undefined), hookProgress, })) this.emitUpdate() @@ -4779,7 +4775,6 @@ export class AppStore extends TypedBaseStore { this.repositoryStateCache.update(repository, () => ({ isCommitting: true, hookProgress: null, - isRunningGitGC: false, })) this.emitUpdate() @@ -4789,7 +4784,6 @@ export class AppStore extends TypedBaseStore { this.repositoryStateCache.update(repository, () => ({ isCommitting: false, hookProgress: null, - isRunningGitGC: false, })) this.emitUpdate() } diff --git a/app/src/lib/stores/repository-state-cache.ts b/app/src/lib/stores/repository-state-cache.ts index 4b95b448cab..96a973b2eac 100644 --- a/app/src/lib/stores/repository-state-cache.ts +++ b/app/src/lib/stores/repository-state-cache.ts @@ -363,7 +363,6 @@ function getInitialRepositoryState(): IRepositoryState { remote: null, isPushPullFetchInProgress: false, isCommitting: false, - isRunningGitGC: false, hookProgress: null, isGeneratingCommitMessage: false, commitToAmend: null, diff --git a/app/src/ui/changes/changes-list.tsx b/app/src/ui/changes/changes-list.tsx index 65cea2cd167..6e0cc4b198a 100644 --- a/app/src/ui/changes/changes-list.tsx +++ b/app/src/ui/changes/changes-list.tsx @@ -175,7 +175,6 @@ interface IChangesListProps { readonly dispatcher: Dispatcher readonly availableWidth: number readonly isCommitting: boolean - readonly isRunningGitGC: boolean readonly hookProgress: HookProgress | null readonly isGeneratingCommitMessage: boolean readonly shouldShowGenerateCommitMessageCallOut: boolean @@ -771,7 +770,6 @@ export class ChangesList extends React.Component< repositoryAccount, dispatcher, isCommitting, - isRunningGitGC, hookProgress, isGeneratingCommitMessage, commitToAmend, @@ -844,7 +842,6 @@ export class ChangesList extends React.Component< focusCommitMessage={this.props.focusCommitMessage} autocompletionProviders={this.props.autocompletionProviders} isCommitting={isCommitting} - isRunningGitGC={isRunningGitGC} hookProgress={hookProgress} isGeneratingCommitMessage={isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index 0951cca1456..3e8df728400 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -109,7 +109,6 @@ interface ICommitMessageProps { readonly repositoryAccount: Account | null readonly autocompletionProviders: ReadonlyArray> readonly isCommitting?: boolean - readonly isRunningGitGC: boolean readonly hookProgress: HookProgress | null readonly isGeneratingCommitMessage?: boolean readonly shouldShowGenerateCommitMessageCallOut?: boolean @@ -1551,43 +1550,25 @@ export class CommitMessage extends React.Component< } private renderCommitProgress() { - const { isCommitting, isRunningGitGC, hookProgress } = this.props - if (!isCommitting) { + const { isCommitting, hookProgress } = this.props + if (!isCommitting || !hookProgress) { + return null // return ( - //
- // - // pre-commit hook finished + //
+ //
Optimizing repository...
+ // //
// ) - return null - } - - if (isRunningGitGC) { - return ( -
- - Optimizing repository… -
- ) - } - - if (!hookProgress) { - return null } const { status, hookName } = hookProgress - // const icon = - // status === 'started' ? ( - // - // ) : status === 'finished' ? ( - // - // ) : status === 'failed' ? ( - // - // ) : null - const text = - status === 'started' + hookName === 'pre-auto-gc' && status === 'finished' + ? 'Optimizing repository…' + : status === 'started' ? `${hookName} hook running…` : status === 'finished' ? `${hookName} hook finished` @@ -1597,8 +1578,10 @@ export class CommitMessage extends React.Component< return (
- - {text} +
{text}
+
) } diff --git a/app/src/ui/changes/filter-changes-list.tsx b/app/src/ui/changes/filter-changes-list.tsx index e9de1782d88..5da924e92d6 100644 --- a/app/src/ui/changes/filter-changes-list.tsx +++ b/app/src/ui/changes/filter-changes-list.tsx @@ -158,7 +158,6 @@ interface IFilterChangesListProps { readonly dispatcher: Dispatcher readonly availableWidth: number readonly isCommitting: boolean - readonly isRunningGitGC: boolean readonly hookProgress: HookProgress | null readonly isGeneratingCommitMessage: boolean readonly shouldShowGenerateCommitMessageCallOut: boolean @@ -868,7 +867,6 @@ export class FilterChangesList extends React.Component< repositoryAccount, dispatcher, isCommitting, - isRunningGitGC, hookProgress, isGeneratingCommitMessage, commitToAmend, @@ -946,7 +944,6 @@ export class FilterChangesList extends React.Component< focusCommitMessage={this.props.focusCommitMessage} autocompletionProviders={this.props.autocompletionProviders} isCommitting={isCommitting} - isRunningGitGC={isRunningGitGC} hookProgress={hookProgress} isGeneratingCommitMessage={isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ diff --git a/app/src/ui/changes/sidebar.tsx b/app/src/ui/changes/sidebar.tsx index 1a039f49649..ffd6b7ccdf9 100644 --- a/app/src/ui/changes/sidebar.tsx +++ b/app/src/ui/changes/sidebar.tsx @@ -57,7 +57,6 @@ interface IChangesSidebarProps { readonly issuesStore: IssuesStore readonly availableWidth: number readonly isCommitting: boolean - readonly isRunningGitGC: boolean readonly hookProgress: HookProgress | null readonly isGeneratingCommitMessage: boolean readonly shouldShowGenerateCommitMessageCallOut: boolean @@ -442,7 +441,6 @@ export class ChangesSidebar extends React.Component { onIgnoreFile={this.onIgnoreFile} onIgnorePattern={this.onIgnorePattern} isCommitting={this.props.isCommitting} - isRunningGitGC={this.props.isRunningGitGC} hookProgress={this.props.hookProgress} isGeneratingCommitMessage={this.props.isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ diff --git a/app/src/ui/commit-message/commit-message-dialog.tsx b/app/src/ui/commit-message/commit-message-dialog.tsx index 66308b249cf..1d7d012702b 100644 --- a/app/src/ui/commit-message/commit-message-dialog.tsx +++ b/app/src/ui/commit-message/commit-message-dialog.tsx @@ -165,7 +165,6 @@ export class CommitMessageDialog extends React.Component< onShowCreateForkDialog={this.onShowCreateForkDialog} accounts={this.props.accounts} isCommitting={false} - isRunningGitGC={false} hookProgress={null} /> diff --git a/app/src/ui/repository.tsx b/app/src/ui/repository.tsx index e38662caee4..0c9cbfe0691 100644 --- a/app/src/ui/repository.tsx +++ b/app/src/ui/repository.tsx @@ -249,7 +249,6 @@ export class RepositoryView extends React.Component< availableWidth={availableWidth} gitHubUserStore={this.props.gitHubUserStore} isCommitting={this.props.state.isCommitting} - isRunningGitGC={this.props.state.isRunningGitGC} hookProgress={this.props.state.hookProgress} isGeneratingCommitMessage={this.props.state.isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ diff --git a/app/styles/ui/changes/_commit-message.scss b/app/styles/ui/changes/_commit-message.scss index aec224953e1..40a9f6e94dc 100644 --- a/app/styles/ui/changes/_commit-message.scss +++ b/app/styles/ui/changes/_commit-message.scss @@ -13,14 +13,46 @@ padding: var(--spacing); .commit-progress { - color: var(--text-secondary-color); + // color: var(--text-secondary-color); display: flex; align-items: center; margin-top: var(--spacing-half); - svg.octicon { + + .description { + flex-grow: 1; + @include ellipsis; + padding: var(--spacing-half) var(--spacing); + + border: var(--base-border); + border-radius: var(--button-border-radius); + border-color: var(--secondary-button-border-color); + border-right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + button { + flex-shrink: 0; + padding: var(--spacing-third); + height: 100%; + border-left-width: 1px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + padding: 0 var(--spacing-half); + > svg.octicon { + height: 12px; + } + + &:hover { + background-color: var(--secondary-button-hover-background); + } + } + + > svg.octicon { margin-right: 3px; height: 12px; + flex-shrink: 0; } } From 5d5c3ddac6b2a46ca8eecb3a6108e8b622e42021 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 12:22:30 +0100 Subject: [PATCH 114/865] Add UI and logic for handling failed Git hooks Introduces a new popup and flow to handle failed Git hooks, allowing users to choose whether to abort or ignore the failure. Updates commit, core, hooks, and app store logic to support the new onHookFailure callback, and adds the HookFailed dialog component for user interaction. --- app/src/lib/git/commit.ts | 4 +- app/src/lib/git/core.ts | 1 + app/src/lib/hooks/hooks-proxy.ts | 68 ++++++++++++++++---------- app/src/lib/hooks/with-hooks-env.ts | 3 +- app/src/lib/stores/app-store.ts | 6 ++- app/src/models/popup.ts | 7 ++- app/src/ui/app.tsx | 11 +++++ app/src/ui/hook-failed/hook-failed.tsx | 54 ++++++++++++++++++++ 8 files changed, 124 insertions(+), 30 deletions(-) create mode 100644 app/src/ui/hook-failed/hook-failed.tsx diff --git a/app/src/lib/git/commit.ts b/app/src/lib/git/commit.ts index 6298b38fffa..d68fcb7f1a1 100644 --- a/app/src/lib/git/commit.ts +++ b/app/src/lib/git/commit.ts @@ -17,7 +17,8 @@ export async function createCommit( message: string, files: ReadonlyArray, amend: boolean = false, - onHookProgress?: (progress: HookProgress) => void + onHookProgress?: (progress: HookProgress) => void, + onHookFailure?: (hookName: string) => Promise<'abort' | 'ignore'> ): Promise { // Clear the staging area, our diffs reflect the difference between the // working directory and the last commit (if any) so our commits should @@ -48,6 +49,7 @@ export async function createCommit( 'pre-auto-gc', ], onHookProgress, + onHookFailure, } ) return parseCommitSHA(result) diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index da3105690b8..c756c7674f7 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -80,6 +80,7 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { readonly interceptHooks?: boolean | string[] readonly onHookProgress?: (progress: HookProgress) => void + readonly onHookFailure?: (hookName: string) => Promise<'abort' | 'ignore'> } /** diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 5faafbf10c7..05465e9e4f4 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -23,10 +23,10 @@ const waitForWritableFinished = (stream: Writable) => { }) } -const exitWithError = ( +const exitWithMessage = ( connection: ProcessProxyConnection, message: string, - exitCode = 1 + exitCode = 0 ) => { return new Promise((resolve, reject) => { connection.stderr.end(`${message}\n`, () => { @@ -42,18 +42,25 @@ const exitWithError = ( }) } +const exitWithError = ( + connection: ProcessProxyConnection, + message: string, + exitCode = 1 +) => exitWithMessage(connection, message, exitCode) + export const createHooksProxy = ( repoHooks: string[], tmpDir: string, gitPath: string, shellEnv: Record, - onHookProgress?: (progress: HookProgress) => void + onHookProgress?: (progress: HookProgress) => void, + onHookFailure?: (hookName: string) => Promise<'abort' | 'ignore'> ) => { - return async (connection: ProcessProxyConnection) => { + return async (conn: ProcessProxyConnection) => { const startTime = Date.now() - const proxyArgs = await connection.getArgs() - const proxyEnv = await connection.getEnv() - const proxyCwd = await connection.getCwd() + const proxyArgs = await conn.getArgs() + const proxyEnv = await conn.getEnv() + const proxyCwd = await conn.getCwd() const hookName = __WIN32__ ? basename(proxyArgs[0]).replace(/\.exe$/i, '') @@ -99,7 +106,7 @@ export const createHooksProxy = ( if (!hooksExecutable) { debug(`hook executable not found for ${hookName}`) await exitWithError( - connection, + conn, `Error: hook executable not found for ${hookName}` ) return @@ -110,12 +117,12 @@ export const createHooksProxy = ( const hasStdin = hooksUsingStdin.includes(hookName) if (hasStdin) { - await pipeline(connection.stdin, createWriteStream(stdinFilePath)) + await pipeline(conn.stdin, createWriteStream(stdinFilePath)) } if (abortController.signal.aborted) { debug(`hook ${hookName} aborted before execution`) - await exitWithError(connection, `Hook ${hookName} aborted`) + await exitWithError(conn, `Hook ${hookName} aborted`) return } @@ -131,7 +138,7 @@ export const createHooksProxy = ( code: number | null signal: NodeJS.Signals | null }>((resolve, reject) => { - connection.on('close', () => abortController.abort()) + conn.on('close', () => abortController.abort()) const child = spawn(gitPath, args, { cwd: proxyCwd, @@ -146,7 +153,7 @@ export const createHooksProxy = ( // hooks never write to stdout // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 - child.stderr.pipe(connection.stderr).on('error', reject) + child.stderr.pipe(conn.stderr).on('error', reject) child.stderr.on('data', data => console.log('hooks stderr:', data.toString()) ) @@ -154,12 +161,9 @@ export const createHooksProxy = ( if (signal !== null) { debug(`hook ${hookName} was killed by signal ${signal}`) - connection.stderr.write( - `error: ${hookName} hook received signal ${signal}\n` - ) } - await waitForWritableFinished(connection.stderr).catch(e => { + await waitForWritableFinished(conn.stderr).catch(e => { debug(`waiting for stderr to finish failed`, e) }) @@ -168,15 +172,27 @@ export const createHooksProxy = ( `executed ${hookName}: exited with code ${code} in ${elapsedSeconds}s` ) - const exitCode = code ?? 1 - await connection - .exit(exitCode) - .catch(err => debug(`failed to exit proxy:`, err)) - .then(() => - onHookProgress?.({ - hookName, - status: exitCode === 0 ? 'finished' : 'failed', - }) - ) + const ignoreError = + code !== null && code !== 0 && onHookFailure + ? (await onHookFailure(hookName)) === 'ignore' + : false + + if (ignoreError) { + debug(`ignoring error from hook ${hookName} as per onHookFailure result`) + } + + const exitCode = ignoreError ? 0 : code ?? 1 + const terminationReason = signal + ? `${hookName} received signal ${signal}` + : `${hookName} exited with code ${exitCode}${ + ignoreError ? ' (ignored by user)' : '' + }` + + await exitWithMessage(conn, terminationReason, exitCode) + + onHookProgress?.({ + hookName, + status: exitCode === 0 ? 'finished' : 'failed', + }) } } diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index 4f293e5c629..a2383ed06f5 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -53,7 +53,8 @@ export async function withHooksEnv( tmpHooksDir, gitPath, shellEnv, - options?.onHookProgress + options?.onHookProgress, + options?.onHookFailure ) const server = createProxyProcessServer( diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index d6ba24b6c29..370314670f5 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -3320,7 +3320,11 @@ export class AppStore extends TypedBaseStore { hookProgress, })) this.emitUpdate() - } + }, + hookName => + new Promise(resolve => { + this._showPopup({ type: PopupType.HookFailed, hookName, resolve }) + }) ) }) diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index 61ca273d8c0..fd58aaa6823 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -103,6 +103,7 @@ export enum PopupType { BypassPushProtection = 'BypassPushProtection', GenerateCommitMessageOverrideWarning = 'GenerateCommitMessageOverrideWarning', GenerateCommitMessageDisclaimer = 'GenerateCommitMessageDisclaimer', + HookFailed = 'HookFailed', } interface IBasePopup { @@ -464,5 +465,9 @@ export type PopupDetail = repository: Repository filesSelected: ReadonlyArray } - + | { + type: PopupType.HookFailed + hookName: string + resolve: (value: 'abort' | 'ignore') => void + } export type Popup = IBasePopup & PopupDetail diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index c410163ccf2..89237631505 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -198,6 +198,7 @@ import { BypassReason, BypassReasonType, } from './secret-scanning/bypass-push-protection-dialog' +import { HookFailed } from './hook-failed/hook-failed' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -2575,6 +2576,16 @@ export class App extends React.Component { /> ) } + case PopupType.HookFailed: { + return ( + + ) + } default: return assertNever(popup, `Unknown popup type: ${popup}`) } diff --git a/app/src/ui/hook-failed/hook-failed.tsx b/app/src/ui/hook-failed/hook-failed.tsx new file mode 100644 index 00000000000..3857d286e3c --- /dev/null +++ b/app/src/ui/hook-failed/hook-failed.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' + +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' + +interface IHookFailedProps { + readonly hookName: string + readonly resolve: (value: 'abort' | 'ignore') => void + readonly onDismissed: () => void +} + +/** A component to confirm and then discard changes. */ +export class HookFailed extends React.Component { + private getDialogTitle() { + return `${this.props.hookName} ${__DARWIN__ ? 'Failed' : 'failed'}` + } + + private onDismissed = () => { + this.props.resolve('abort') + this.props.onDismissed() + } + + private onIgnore = () => { + this.props.resolve('ignore') + this.props.onDismissed() + } + + public render() { + return ( + + +

+ The {this.props.hookName} hook failed. What would you like to do? +

+
+ + + + +
+ ) + } +} From ca60fc14399cdb603f6642746e97a2d710f199c1 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 12:48:18 +0100 Subject: [PATCH 115/865] Improve hook failure handling and dialog UI Added a list of hooks whose failures should be ignored, refined error handling logic in hooks-proxy, and updated the hook failure dialog to clarify the cancel action as 'Abort commit'. --- app/src/lib/hooks/hooks-proxy.ts | 57 +++++++++++++------------- app/src/ui/hook-failed/hook-failed.tsx | 1 + 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 05465e9e4f4..8c44feb5209 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -3,41 +3,41 @@ import { randomBytes } from 'crypto' import { createWriteStream } from 'fs' import { basename, join } from 'path' import { ProcessProxyConnection } from 'process-proxy' -import { Writable } from 'stream' import { pipeline } from 'stream/promises' import type { HookProgress } from '../git' const hooksUsingStdin = ['post-rewrite'] +const ignoredOnFailureHooks = [ + 'post-applypatch', + 'post-commit', + // The exit code from post-checkout doesn't stop the checkout but it does set + // the overall command's exit code. I don't believe we want to show an error + // to the user if this hook fails though. + 'post-checkout', + 'post-merge', + // Again, the exit code here does affect Git in so far that it won't run + // git-gc but it's not something we should alert the user about. + 'pre-auto-gc', +] const debug = (message: string, error?: Error) => { log.debug(`hooks: ${message}`, error) } -const waitForWritableFinished = (stream: Writable) => { - return new Promise(resolve => { - if (stream.writableFinished) { - resolve() - } else { - stream.once('finish', () => resolve()) - } - }) -} - const exitWithMessage = ( connection: ProcessProxyConnection, message: string, exitCode = 0 ) => { - return new Promise((resolve, reject) => { - connection.stderr.end(`${message}\n`, () => { - connection.exit(exitCode).then(resolve, err => { - debug( - `failed to exit proxy: ${ - err instanceof Error ? err.message : String(err) - }` - ) - resolve() - }) + return new Promise(resolve => { + connection.stderr.end(`${message}\n`) + connection.exit(exitCode).then(resolve, err => { + debug( + `failed to exit proxy: ${ + err instanceof Error ? err.message : String(err) + }` + ) + resolve() }) }) } @@ -153,7 +153,7 @@ export const createHooksProxy = ( // hooks never write to stdout // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 - child.stderr.pipe(conn.stderr).on('error', reject) + child.stderr.pipe(conn.stderr, { end: false }).on('error', reject) child.stderr.on('data', data => console.log('hooks stderr:', data.toString()) ) @@ -163,17 +163,16 @@ export const createHooksProxy = ( debug(`hook ${hookName} was killed by signal ${signal}`) } - await waitForWritableFinished(conn.stderr).catch(e => { - debug(`waiting for stderr to finish failed`, e) - }) - const elapsedSeconds = (Date.now() - startTime) / 1000 debug( `executed ${hookName}: exited with code ${code} in ${elapsedSeconds}s` ) const ignoreError = - code !== null && code !== 0 && onHookFailure + code !== null && + code !== 0 && + !ignoredOnFailureHooks.includes(hookName) && + onHookFailure ? (await onHookFailure(hookName)) === 'ignore' : false @@ -183,8 +182,8 @@ export const createHooksProxy = ( const exitCode = ignoreError ? 0 : code ?? 1 const terminationReason = signal - ? `${hookName} received signal ${signal}` - : `${hookName} exited with code ${exitCode}${ + ? `${hookName} hook killed by signal ${signal}` + : `${hookName} hook exited with code ${exitCode}${ ignoreError ? ' (ignored by user)' : '' }` diff --git a/app/src/ui/hook-failed/hook-failed.tsx b/app/src/ui/hook-failed/hook-failed.tsx index 3857d286e3c..7a2b4bc67b8 100644 --- a/app/src/ui/hook-failed/hook-failed.tsx +++ b/app/src/ui/hook-failed/hook-failed.tsx @@ -46,6 +46,7 @@ export class HookFailed extends React.Component { From b0aeb11cb806956b98a46c2f663076d371ea0d68 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 13:34:50 +0100 Subject: [PATCH 116/865] Update hooks-proxy.ts --- app/src/lib/hooks/hooks-proxy.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 8c44feb5209..68077d6b4e4 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -68,6 +68,8 @@ export const createHooksProxy = ( const abortController = new AbortController() + conn.stderr.write(`Running ${hookName} hook...\n`) + onHookProgress?.({ hookName, status: 'started', From deca57baad22366279d091a424dd3490cdab79be Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 14:17:22 +0100 Subject: [PATCH 117/865] Add commit progress dialog with live terminal output Introduces a CommitProgress popup that displays live terminal output during commit operations. Refactors commit logic to support subscribing to commit output, updates state management to track output listeners, and wires up UI components to show the progress dialog when requested. --- app/src/lib/app-state.ts | 7 +- app/src/lib/git/commit.ts | 23 ++++-- app/src/lib/git/core.ts | 22 ++++++ app/src/lib/stores/app-store.ts | 23 +++--- app/src/lib/stores/repository-state-cache.ts | 1 + app/src/models/popup.ts | 6 ++ app/src/ui/app.tsx | 10 +++ app/src/ui/changes/changes-list.tsx | 2 + app/src/ui/changes/commit-message.tsx | 12 ++- app/src/ui/changes/filter-changes-list.tsx | 2 + app/src/ui/changes/sidebar.tsx | 2 + .../commit-message/commit-message-dialog.tsx | 1 + .../ui/commit-progress/commit-progress.tsx | 79 +++++++++++++++++++ app/src/ui/repository.tsx | 17 ++++ 14 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 app/src/ui/commit-progress/commit-progress.tsx diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index c57ce7f0275..abc42cae942 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -44,7 +44,11 @@ import { MultiCommitOperationDetail, MultiCommitOperationStep, } from '../models/multi-commit-operation' -import type { HookProgress, IChangesetData } from './git' +import type { + HookProgress, + IChangesetData, + TerminalOutputListener, +} from './git' import { Popup } from '../models/popup' import { RepoRulesInfo } from '../models/repo-rules' import { IAPIRepoRuleset } from './api' @@ -550,6 +554,7 @@ export interface IRepositoryState { readonly lastFetched: Date | null readonly hookProgress: HookProgress | null + readonly subscribeToCommitOutput: TerminalOutputListener | null /** * If we're currently working on switching to a new branch this diff --git a/app/src/lib/git/commit.ts b/app/src/lib/git/commit.ts index d68fcb7f1a1..166e5350fe5 100644 --- a/app/src/lib/git/commit.ts +++ b/app/src/lib/git/commit.ts @@ -1,4 +1,9 @@ -import { git, HookProgress, parseCommitSHA } from './core' +import { + git, + HookProgress, + parseCommitSHA, + TerminalOutputCallback, +} from './core' import { stageFiles } from './update-index' import { Repository } from '../../models/repository' import { WorkingDirectoryFileChange } from '../../models/status' @@ -16,9 +21,12 @@ export async function createCommit( repository: Repository, message: string, files: ReadonlyArray, - amend: boolean = false, - onHookProgress?: (progress: HookProgress) => void, - onHookFailure?: (hookName: string) => Promise<'abort' | 'ignore'> + options?: { + amend?: boolean + onHookProgress?: (progress: HookProgress) => void + onHookFailure?: (hookName: string) => Promise<'abort' | 'ignore'> + onTerminalOutputAvailable?: TerminalOutputCallback + } ): Promise { // Clear the staging area, our diffs reflect the difference between the // working directory and the last commit (if any) so our commits should @@ -29,7 +37,7 @@ export async function createCommit( const args = ['-F', '-'] - if (amend) { + if (options?.amend) { args.push('--amend') } @@ -48,8 +56,9 @@ export async function createCommit( 'post-rewrite', 'pre-auto-gc', ], - onHookProgress, - onHookFailure, + onHookProgress: options?.onHookProgress, + onHookFailure: options?.onHookFailure, + onTerminalOutputAvailable: options?.onTerminalOutputAvailable, } ) return parseCommitSHA(result) diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index c756c7674f7..2b7713c57d3 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -37,6 +37,12 @@ export const isMaxBufferExceededError = ( ) } +export type TerminalOutputListener = (cb: (chunk: Buffer | string) => void) => { + unsubscribe: () => void +} + +export type TerminalOutputCallback = (subscribe: TerminalOutputListener) => void + export type HookProgress = | { readonly hookName: string @@ -81,6 +87,8 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { readonly interceptHooks?: boolean | string[] readonly onHookProgress?: (progress: HookProgress) => void readonly onHookFailure?: (hookName: string) => Promise<'abort' | 'ignore'> + + readonly onTerminalOutputAvailable?: TerminalOutputCallback } /** @@ -242,6 +250,20 @@ export async function git( // Keep at most 256kb of combined stderr and stdout output. This is used // to provide more context in error messages. opts.processCallback = process => { + if (options?.onTerminalOutputAvailable) { + options.onTerminalOutputAvailable(function (cb) { + process.stdout?.on('data', cb) + process.stderr?.on('data', cb) + + return { + unsubscribe: () => { + process.stdout?.off('data', cb) + process.stderr?.off('data', cb) + }, + } + }) + } + const terminalStream = createTerminalStream() const tailStream = createTailStream(256 * 1024, { encoding: 'utf8' }) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 370314670f5..39441ea6d2f 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -3309,23 +3309,26 @@ export class AppStore extends TypedBaseStore { return this.withIsCommitting(repository, async () => { const result = await gitStore.performFailableOperation(async () => { const message = await formatCommitMessage(repository, context) - return createCommit( - repository, - message, - selectedFiles, - context.amend, - hookProgress => { + return createCommit(repository, message, selectedFiles, { + amend: context.amend, + onHookProgress: hookProgress => { this.repositoryStateCache.update(repository, state => ({ ...state, hookProgress, })) this.emitUpdate() }, - hookName => + onHookFailure: hookName => new Promise(resolve => { this._showPopup({ type: PopupType.HookFailed, hookName, resolve }) - }) - ) + }), + onTerminalOutputAvailable: subscribeToCommitOutput => { + this.repositoryStateCache.update(repository, state => ({ + ...state, + subscribeToCommitOutput, + })) + }, + }) }) if (result !== undefined) { @@ -4779,6 +4782,7 @@ export class AppStore extends TypedBaseStore { this.repositoryStateCache.update(repository, () => ({ isCommitting: true, hookProgress: null, + subscribeToCommitOutput: null, })) this.emitUpdate() @@ -4788,6 +4792,7 @@ export class AppStore extends TypedBaseStore { this.repositoryStateCache.update(repository, () => ({ isCommitting: false, hookProgress: null, + subscribeToCommitOutput: null, })) this.emitUpdate() } diff --git a/app/src/lib/stores/repository-state-cache.ts b/app/src/lib/stores/repository-state-cache.ts index 96a973b2eac..76bd1a7dd6f 100644 --- a/app/src/lib/stores/repository-state-cache.ts +++ b/app/src/lib/stores/repository-state-cache.ts @@ -364,6 +364,7 @@ function getInitialRepositoryState(): IRepositoryState { isPushPullFetchInProgress: false, isCommitting: false, hookProgress: null, + subscribeToCommitOutput: null, isGeneratingCommitMessage: false, commitToAmend: null, lastFetched: null, diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index fd58aaa6823..5774a88fdc1 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -25,6 +25,7 @@ import { UnreachableCommitsTab } from '../ui/history/unreachable-commits-dialog' import { IAPIComment } from '../lib/api' import { ISecretScanResult } from '../ui/secret-scanning/push-protection-error-dialog' import { BypassReasonType } from '../ui/secret-scanning/bypass-push-protection-dialog' +import { TerminalOutputListener } from '../lib/git' export enum PopupType { RenameBranch = 'RenameBranch', @@ -104,6 +105,7 @@ export enum PopupType { GenerateCommitMessageOverrideWarning = 'GenerateCommitMessageOverrideWarning', GenerateCommitMessageDisclaimer = 'GenerateCommitMessageDisclaimer', HookFailed = 'HookFailed', + CommitProgress = 'CommitProgress', } interface IBasePopup { @@ -470,4 +472,8 @@ export type PopupDetail = hookName: string resolve: (value: 'abort' | 'ignore') => void } + | { + type: PopupType.CommitProgress + subscribeToCommitOutput: TerminalOutputListener + } export type Popup = IBasePopup & PopupDetail diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 89237631505..e17f4a72f4c 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -199,6 +199,7 @@ import { BypassReasonType, } from './secret-scanning/bypass-push-protection-dialog' import { HookFailed } from './hook-failed/hook-failed' +import { CommitProgress } from './commit-progress/commit-progress' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -2586,6 +2587,15 @@ export class App extends React.Component { /> ) } + case PopupType.CommitProgress: { + return ( + + ) + } default: return assertNever(popup, `Unknown popup type: ${popup}`) } diff --git a/app/src/ui/changes/changes-list.tsx b/app/src/ui/changes/changes-list.tsx index 6e0cc4b198a..6f38aaab071 100644 --- a/app/src/ui/changes/changes-list.tsx +++ b/app/src/ui/changes/changes-list.tsx @@ -176,6 +176,7 @@ interface IChangesListProps { readonly availableWidth: number readonly isCommitting: boolean readonly hookProgress: HookProgress | null + readonly onShowCommitProgress: (() => void) | undefined readonly isGeneratingCommitMessage: boolean readonly shouldShowGenerateCommitMessageCallOut: boolean readonly commitToAmend: Commit | null @@ -843,6 +844,7 @@ export class ChangesList extends React.Component< autocompletionProviders={this.props.autocompletionProviders} isCommitting={isCommitting} hookProgress={hookProgress} + onShowCommitProgress={this.props.onShowCommitProgress} isGeneratingCommitMessage={isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ shouldShowGenerateCommitMessageCallOut diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index 3e8df728400..7cf60fa016f 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -110,6 +110,7 @@ interface ICommitMessageProps { readonly autocompletionProviders: ReadonlyArray> readonly isCommitting?: boolean readonly hookProgress: HookProgress | null + readonly onShowCommitProgress: (() => void) | undefined readonly isGeneratingCommitMessage?: boolean readonly shouldShowGenerateCommitMessageCallOut?: boolean readonly commitToAmend: Commit | null @@ -1579,9 +1580,14 @@ export class CommitMessage extends React.Component< return (
{text}
- + {this.props.onShowCommitProgress && ( + + )}
) } diff --git a/app/src/ui/changes/filter-changes-list.tsx b/app/src/ui/changes/filter-changes-list.tsx index 5da924e92d6..c56e9981cea 100644 --- a/app/src/ui/changes/filter-changes-list.tsx +++ b/app/src/ui/changes/filter-changes-list.tsx @@ -159,6 +159,7 @@ interface IFilterChangesListProps { readonly availableWidth: number readonly isCommitting: boolean readonly hookProgress: HookProgress | null + readonly onShowCommitProgress?: () => void readonly isGeneratingCommitMessage: boolean readonly shouldShowGenerateCommitMessageCallOut: boolean readonly commitToAmend: Commit | null @@ -945,6 +946,7 @@ export class FilterChangesList extends React.Component< autocompletionProviders={this.props.autocompletionProviders} isCommitting={isCommitting} hookProgress={hookProgress} + onShowCommitProgress={this.props.onShowCommitProgress} isGeneratingCommitMessage={isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ shouldShowGenerateCommitMessageCallOut diff --git a/app/src/ui/changes/sidebar.tsx b/app/src/ui/changes/sidebar.tsx index ffd6b7ccdf9..258cb1b701c 100644 --- a/app/src/ui/changes/sidebar.tsx +++ b/app/src/ui/changes/sidebar.tsx @@ -58,6 +58,7 @@ interface IChangesSidebarProps { readonly availableWidth: number readonly isCommitting: boolean readonly hookProgress: HookProgress | null + readonly onShowCommitProgress: (() => void) | undefined readonly isGeneratingCommitMessage: boolean readonly shouldShowGenerateCommitMessageCallOut: boolean readonly commitToAmend: Commit | null @@ -442,6 +443,7 @@ export class ChangesSidebar extends React.Component { onIgnorePattern={this.onIgnorePattern} isCommitting={this.props.isCommitting} hookProgress={this.props.hookProgress} + onShowCommitProgress={this.props.onShowCommitProgress} isGeneratingCommitMessage={this.props.isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ this.props.shouldShowGenerateCommitMessageCallOut diff --git a/app/src/ui/commit-message/commit-message-dialog.tsx b/app/src/ui/commit-message/commit-message-dialog.tsx index 1d7d012702b..684b50890ec 100644 --- a/app/src/ui/commit-message/commit-message-dialog.tsx +++ b/app/src/ui/commit-message/commit-message-dialog.tsx @@ -166,6 +166,7 @@ export class CommitMessageDialog extends React.Component< accounts={this.props.accounts} isCommitting={false} hookProgress={null} + onShowCommitProgress={undefined} /> diff --git a/app/src/ui/commit-progress/commit-progress.tsx b/app/src/ui/commit-progress/commit-progress.tsx new file mode 100644 index 00000000000..cf64d9def0b --- /dev/null +++ b/app/src/ui/commit-progress/commit-progress.tsx @@ -0,0 +1,79 @@ +import * as React from 'react' + +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { TerminalOutputListener } from '../../lib/git' + +interface ICommitProgressProps { + readonly subscribeToCommitOutput: TerminalOutputListener + readonly onDismissed: () => void +} + +interface ICommitProgressState { + readonly output: ReadonlyArray +} + +/** A component to confirm and then discard changes. */ +export class CommitProgress extends React.Component< + ICommitProgressProps, + ICommitProgressState +> { + private unsubscribe?: () => void | null + + public constructor(props: ICommitProgressProps) { + super(props) + this.state = { + output: [], + } + } + + private onDismissed = () => { + this.unsubscribe?.() + this.unsubscribe = undefined + this.props.onDismissed() + } + + public componentDidMount() { + const { unsubscribe } = this.props.subscribeToCommitOutput(chunk => { + this.setState(prevState => ({ + output: [...prevState.output, chunk.toString()], + })) + }) + this.unsubscribe = unsubscribe + } + + public componentWillUnmount() { + this.unsubscribe?.() + this.unsubscribe = undefined + } + + public render() { + return ( + + +
+            {this.state.output.join('')}
+          
+
+ + + + +
+ ) + } +} diff --git a/app/src/ui/repository.tsx b/app/src/ui/repository.tsx index 0c9cbfe0691..597aa3f7da7 100644 --- a/app/src/ui/repository.tsx +++ b/app/src/ui/repository.tsx @@ -34,6 +34,7 @@ import { DragType } from '../models/drag-drop' import { PullRequestSuggestedNextAction } from '../models/pull-request' import { clamp } from '../lib/clamp' import { Emoji } from '../lib/emoji' +import { PopupType } from '../models/popup' interface IRepositoryViewProps { readonly repository: Repository @@ -206,6 +207,17 @@ export class RepositoryView extends React.Component< ) } + private onShowCommitProgress = () => { + if (!this.props.state.subscribeToCommitOutput) { + return + } + + this.props.dispatcher.showPopup({ + type: PopupType.CommitProgress, + subscribeToCommitOutput: this.props.state.subscribeToCommitOutput, + }) + } + private renderChangesSidebar(): JSX.Element { const tip = this.props.state.branchesState.tip @@ -250,6 +262,11 @@ export class RepositoryView extends React.Component< gitHubUserStore={this.props.gitHubUserStore} isCommitting={this.props.state.isCommitting} hookProgress={this.props.state.hookProgress} + onShowCommitProgress={ + this.props.state.subscribeToCommitOutput + ? this.onShowCommitProgress + : undefined + } isGeneratingCommitMessage={this.props.state.isGeneratingCommitMessage} shouldShowGenerateCommitMessageCallOut={ this.props.shouldShowGenerateCommitMessageCallOut From 88f7a79b61b0028bdd7cbf733bf72fef0ab54a6a Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 15:01:35 +0100 Subject: [PATCH 118/865] Integrate xterm.js for commit progress output Replaces the custom terminal output rendering in the commit progress dialog with xterm.js for improved display and performance. Adds @xterm/xterm as a dependency, updates styles to support the new terminal, and refactors related logic in the commit progress component and git core. --- app/package.json | 1 + app/src/lib/git/core.ts | 7 +++ .../ui/commit-progress/commit-progress.tsx | 50 ++++++++----------- app/styles/_vendor.scss | 5 +- app/styles/ui/_dialog.scss | 1 + app/styles/ui/dialogs/_commit_progress.scss | 8 +++ app/yarn.lock | 10 ++++ 7 files changed, 50 insertions(+), 32 deletions(-) create mode 100644 app/styles/ui/dialogs/_commit_progress.scss diff --git a/app/package.json b/app/package.json index a61e952a7dc..bb6a5fbb74c 100644 --- a/app/package.json +++ b/app/package.json @@ -19,6 +19,7 @@ "dependencies": { "@floating-ui/react-dom": "^2.1.2", "@github/alive-client": "^1.2.0", + "@xterm/xterm": "^5.5.0", "app-path": "^3.3.0", "byline": "^5.0.0", "chalk": "^2.3.0", diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 2b7713c57d3..883a9a0966a 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -246,12 +246,15 @@ export async function git( // this property is to provide "terminal-like" output to the user when a Git // command fails. let terminalOutput = '' + const terminalChunks: string[] = [] // Keep at most 256kb of combined stderr and stdout output. This is used // to provide more context in error messages. opts.processCallback = process => { if (options?.onTerminalOutputAvailable) { options.onTerminalOutputAvailable(function (cb) { + terminalChunks.forEach(chunk => cb(chunk)) + process.stdout?.on('data', cb) process.stderr?.on('data', cb) @@ -274,6 +277,10 @@ export async function git( process.stdout?.pipe(terminalStream, { end: false }) process.stderr?.pipe(terminalStream, { end: false }) + + process.stdout?.on('data', chunk => terminalChunks.push(chunk)) + process.stderr?.on('data', chunk => terminalChunks.push(chunk)) + process.on('close', () => terminalStream.end()) options?.processCallback?.(process) } diff --git a/app/src/ui/commit-progress/commit-progress.tsx b/app/src/ui/commit-progress/commit-progress.tsx index cf64d9def0b..ccb7d00e5fb 100644 --- a/app/src/ui/commit-progress/commit-progress.tsx +++ b/app/src/ui/commit-progress/commit-progress.tsx @@ -3,29 +3,18 @@ import * as React from 'react' import { Dialog, DialogContent, DialogFooter } from '../dialog' import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' import { TerminalOutputListener } from '../../lib/git' +import { Terminal } from '@xterm/xterm' interface ICommitProgressProps { readonly subscribeToCommitOutput: TerminalOutputListener readonly onDismissed: () => void } -interface ICommitProgressState { - readonly output: ReadonlyArray -} - /** A component to confirm and then discard changes. */ -export class CommitProgress extends React.Component< - ICommitProgressProps, - ICommitProgressState -> { +export class CommitProgress extends React.Component { private unsubscribe?: () => void | null - - public constructor(props: ICommitProgressProps) { - super(props) - this.state = { - output: [], - } - } + private terminalRef = React.createRef() + private terminal: Terminal | null = null private onDismissed = () => { this.unsubscribe?.() @@ -34,11 +23,24 @@ export class CommitProgress extends React.Component< } public componentDidMount() { + if (this.terminalRef.current) { + this.terminal = new Terminal({ + disableStdin: true, + convertEol: true, + rows: 20, + cols: 80, + fontSize: 12, + fontFamily: + "SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace, 'Apple Color Emoji', 'Segoe UI', 'Segoe UI Emoji', 'Segoe UI Symbol'", + }) + + this.terminal.open(this.terminalRef.current) + } + const { unsubscribe } = this.props.subscribeToCommitOutput(chunk => { - this.setState(prevState => ({ - output: [...prevState.output, chunk.toString()], - })) + this.terminal?.write(chunk) }) + this.unsubscribe = unsubscribe } @@ -50,21 +52,13 @@ export class CommitProgress extends React.Component< public render() { return ( -
-            {this.state.output.join('')}
-          
+
diff --git a/app/styles/_vendor.scss b/app/styles/_vendor.scss index 3793cbbeb9e..00687d537fa 100644 --- a/app/styles/_vendor.scss +++ b/app/styles/_vendor.scss @@ -1,5 +1,2 @@ @import '~react-virtualized/styles.css'; -@import '~codemirror/lib/codemirror.css'; -@import '~codemirror/addon/dialog/dialog.css'; -@import '~codemirror/theme/solarized.css'; -@import '~codemirror/addon/scroll/simplescrollbars.css'; +@import '~@xterm/xterm/css/xterm.css'; diff --git a/app/styles/ui/_dialog.scss b/app/styles/ui/_dialog.scss index 9e04fd72144..079365ba73a 100644 --- a/app/styles/ui/_dialog.scss +++ b/app/styles/ui/_dialog.scss @@ -25,6 +25,7 @@ @import 'dialogs/unknown-authors'; @import 'dialogs/icon_preview'; @import 'dialogs/push-protection'; +@import 'dialogs/commit_progress'; // The styles herein attempt to follow a flow where margins are only applied // to the bottom of elements (with the exception of the last child). This to diff --git a/app/styles/ui/dialogs/_commit_progress.scss b/app/styles/ui/dialogs/_commit_progress.scss new file mode 100644 index 00000000000..e0d3f13dc3e --- /dev/null +++ b/app/styles/ui/dialogs/_commit_progress.scss @@ -0,0 +1,8 @@ +#commit-progress-dialog { + // Make sure 80 cols fit comfortably + max-width: 800px; + + .xterm { + padding: var(--spacing); + } +} diff --git a/app/yarn.lock b/app/yarn.lock index 80a27df2997..fb9f41d24a2 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -73,6 +73,16 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@xterm/addon-fit@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@xterm/addon-fit/-/addon-fit-0.10.0.tgz#bebf87fadd74e3af30fdcdeef47030e2592c6f55" + integrity sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ== + +"@xterm/xterm@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0.tgz#275fb8f6e14afa6e8a0c05d4ebc94523ff775396" + integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A== + ansi-html@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" From a5feb9676551fcf82d4b1adb9ab728485de382f1 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 15:02:20 +0100 Subject: [PATCH 119/865] Update yarn.lock --- app/yarn.lock | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/yarn.lock b/app/yarn.lock index fb9f41d24a2..a7df44be8f8 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -73,11 +73,6 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== -"@xterm/addon-fit@^0.10.0": - version "0.10.0" - resolved "https://registry.yarnpkg.com/@xterm/addon-fit/-/addon-fit-0.10.0.tgz#bebf87fadd74e3af30fdcdeef47030e2592c6f55" - integrity sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ== - "@xterm/xterm@^5.5.0": version "5.5.0" resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0.tgz#275fb8f6e14afa6e8a0c05d4ebc94523ff775396" From d6cbfe577b541b584a0506468db44af627785371 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 16:02:40 +0100 Subject: [PATCH 120/865] Show hook failure terminal output in dialog Adds support for displaying the terminal output from failed Git hooks in the hook failure dialog using xterm.js. Updates relevant types, popup models, and UI components to pass and render the output, and introduces new styles for the dialog. --- app/src/lib/git/commit.ts | 5 +++- app/src/lib/git/core.ts | 5 +++- app/src/lib/hooks/hooks-proxy.ts | 18 +++++++++--- app/src/lib/stores/app-store.ts | 9 ++++-- app/src/models/popup.ts | 1 + app/src/ui/app.tsx | 1 + .../ui/commit-progress/commit-progress.tsx | 2 ++ app/src/ui/hook-failed/hook-failed.tsx | 29 ++++++++++++++++++- app/styles/ui/_dialog.scss | 1 + app/styles/ui/dialogs/_hook-failed.scss | 8 +++++ 10 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 app/styles/ui/dialogs/_hook-failed.scss diff --git a/app/src/lib/git/commit.ts b/app/src/lib/git/commit.ts index 166e5350fe5..b2e245b5100 100644 --- a/app/src/lib/git/commit.ts +++ b/app/src/lib/git/commit.ts @@ -24,7 +24,10 @@ export async function createCommit( options?: { amend?: boolean onHookProgress?: (progress: HookProgress) => void - onHookFailure?: (hookName: string) => Promise<'abort' | 'ignore'> + onHookFailure?: ( + hookName: string, + terminalOutput: string + ) => Promise<'abort' | 'ignore'> onTerminalOutputAvailable?: TerminalOutputCallback } ): Promise { diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 883a9a0966a..7bd9da26f9f 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -86,7 +86,10 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { readonly interceptHooks?: boolean | string[] readonly onHookProgress?: (progress: HookProgress) => void - readonly onHookFailure?: (hookName: string) => Promise<'abort' | 'ignore'> + readonly onHookFailure?: ( + hookName: string, + terminalOutput: string + ) => Promise<'abort' | 'ignore'> readonly onTerminalOutputAvailable?: TerminalOutputCallback } diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 68077d6b4e4..b3c00f0bcaa 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -54,7 +54,10 @@ export const createHooksProxy = ( gitPath: string, shellEnv: Record, onHookProgress?: (progress: HookProgress) => void, - onHookFailure?: (hookName: string) => Promise<'abort' | 'ignore'> + onHookFailure?: ( + hookName: string, + terminalOutput: string + ) => Promise<'abort' | 'ignore'> ) => { return async (conn: ProcessProxyConnection) => { const startTime = Date.now() @@ -136,6 +139,9 @@ export const createHooksProxy = ( '--', ...proxyArgs.slice(1), ] + + const terminalOutput: Buffer[] = [] + const { code, signal } = await new Promise<{ code: number | null signal: NodeJS.Signals | null @@ -156,9 +162,10 @@ export const createHooksProxy = ( // hooks never write to stdout // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 child.stderr.pipe(conn.stderr, { end: false }).on('error', reject) - child.stderr.on('data', data => + child.stderr.on('data', data => { + terminalOutput.push(data) console.log('hooks stderr:', data.toString()) - ) + }) }) if (signal !== null) { @@ -175,7 +182,10 @@ export const createHooksProxy = ( code !== 0 && !ignoredOnFailureHooks.includes(hookName) && onHookFailure - ? (await onHookFailure(hookName)) === 'ignore' + ? (await onHookFailure( + hookName, + Buffer.concat(terminalOutput).toString() + )) === 'ignore' : false if (ignoreError) { diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 39441ea6d2f..cf5bcd039d7 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -3318,9 +3318,14 @@ export class AppStore extends TypedBaseStore { })) this.emitUpdate() }, - onHookFailure: hookName => + onHookFailure: (hookName, terminalOutput) => new Promise(resolve => { - this._showPopup({ type: PopupType.HookFailed, hookName, resolve }) + this._showPopup({ + type: PopupType.HookFailed, + hookName, + terminalOutput, + resolve, + }) }), onTerminalOutputAvailable: subscribeToCommitOutput => { this.repositoryStateCache.update(repository, state => ({ diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index 5774a88fdc1..9977e6d8c32 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -470,6 +470,7 @@ export type PopupDetail = | { type: PopupType.HookFailed hookName: string + terminalOutput: string resolve: (value: 'abort' | 'ignore') => void } | { diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index e17f4a72f4c..57e13cafaeb 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -2582,6 +2582,7 @@ export class App extends React.Component { diff --git a/app/src/ui/commit-progress/commit-progress.tsx b/app/src/ui/commit-progress/commit-progress.tsx index ccb7d00e5fb..2dae760c37d 100644 --- a/app/src/ui/commit-progress/commit-progress.tsx +++ b/app/src/ui/commit-progress/commit-progress.tsx @@ -47,6 +47,8 @@ export class CommitProgress extends React.Component { public componentWillUnmount() { this.unsubscribe?.() this.unsubscribe = undefined + this.terminal?.dispose() + this.terminal = null } public render() { diff --git a/app/src/ui/hook-failed/hook-failed.tsx b/app/src/ui/hook-failed/hook-failed.tsx index 7a2b4bc67b8..34dcd7599d6 100644 --- a/app/src/ui/hook-failed/hook-failed.tsx +++ b/app/src/ui/hook-failed/hook-failed.tsx @@ -2,15 +2,20 @@ import * as React from 'react' import { Dialog, DialogContent, DialogFooter } from '../dialog' import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { Terminal } from '@xterm/xterm' interface IHookFailedProps { readonly hookName: string + readonly terminalOutput: string readonly resolve: (value: 'abort' | 'ignore') => void readonly onDismissed: () => void } /** A component to confirm and then discard changes. */ export class HookFailed extends React.Component { + private terminalRef = React.createRef() + private terminal: Terminal | null = null + private getDialogTitle() { return `${this.props.hookName} ${__DARWIN__ ? 'Failed' : 'failed'}` } @@ -25,10 +30,31 @@ export class HookFailed extends React.Component { this.props.onDismissed() } + public componentDidMount(): void { + if (this.terminalRef.current) { + this.terminal = new Terminal({ + disableStdin: true, + convertEol: true, + rows: 10, + cols: 80, + fontSize: 12, + fontFamily: + "SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace, 'Apple Color Emoji', 'Segoe UI', 'Segoe UI Emoji', 'Segoe UI Symbol'", + }) + this.terminal.open(this.terminalRef.current) + this.terminal.write(this.props.terminalOutput) + } + } + + public componentWillUnmount(): void { + this.terminal?.dispose() + this.terminal = null + } + public render() { return ( {

The {this.props.hookName} hook failed. What would you like to do?

+
diff --git a/app/styles/ui/_dialog.scss b/app/styles/ui/_dialog.scss index 079365ba73a..469d08dc402 100644 --- a/app/styles/ui/_dialog.scss +++ b/app/styles/ui/_dialog.scss @@ -25,6 +25,7 @@ @import 'dialogs/unknown-authors'; @import 'dialogs/icon_preview'; @import 'dialogs/push-protection'; +@import 'dialogs/hook-failed'; @import 'dialogs/commit_progress'; // The styles herein attempt to follow a flow where margins are only applied diff --git a/app/styles/ui/dialogs/_hook-failed.scss b/app/styles/ui/dialogs/_hook-failed.scss new file mode 100644 index 00000000000..969ea7d0e73 --- /dev/null +++ b/app/styles/ui/dialogs/_hook-failed.scss @@ -0,0 +1,8 @@ +#hook-failed-dialog { + // Make sure 80 cols fit comfortably + max-width: 800px; + + .xterm { + padding: var(--spacing); + } +} From afa4b9bc3cb91dd74c057e5ba0ade7f9cfc3c331 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 16:06:04 +0100 Subject: [PATCH 121/865] Handle commit hook abort resolution in AppStore Introduces logic to track if a commit hook was aborted and prevents error propagation when aborted. This ensures that aborting a commit hook does not result in unnecessary error handling. --- app/src/lib/stores/app-store.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index cf5bcd039d7..7083bc23db2 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -3309,6 +3309,7 @@ export class AppStore extends TypedBaseStore { return this.withIsCommitting(repository, async () => { const result = await gitStore.performFailableOperation(async () => { const message = await formatCommitMessage(repository, context) + let aborted = false return createCommit(repository, message, selectedFiles, { amend: context.amend, onHookProgress: hookProgress => { @@ -3324,7 +3325,12 @@ export class AppStore extends TypedBaseStore { type: PopupType.HookFailed, hookName, terminalOutput, - resolve, + resolve: resolution => { + if (resolution === 'abort') { + aborted = true + } + resolve(resolution) + }, }) }), onTerminalOutputAvailable: subscribeToCommitOutput => { @@ -3333,7 +3339,7 @@ export class AppStore extends TypedBaseStore { subscribeToCommitOutput, })) }, - }) + }).catch(err => (aborted ? undefined : Promise.reject(err))) }) if (result !== undefined) { From 3a2f6a08c3d6113755714f65d71fcb2522de779e Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 16:06:06 +0100 Subject: [PATCH 122/865] Update hook-failed.tsx --- app/src/ui/hook-failed/hook-failed.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/ui/hook-failed/hook-failed.tsx b/app/src/ui/hook-failed/hook-failed.tsx index 34dcd7599d6..80e5620b24e 100644 --- a/app/src/ui/hook-failed/hook-failed.tsx +++ b/app/src/ui/hook-failed/hook-failed.tsx @@ -35,7 +35,7 @@ export class HookFailed extends React.Component { this.terminal = new Terminal({ disableStdin: true, convertEol: true, - rows: 10, + rows: 12, cols: 80, fontSize: 12, fontFamily: From 8063130bb090dfc58fc97929cb1c8c09264b8015 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 18:53:59 +0100 Subject: [PATCH 123/865] Add commit error context for improved error handling Introduces a new 'commit' kind to GitErrorContext, passes commit context to failable operations in AppStore, and updates AppError UI to display commit-specific error messages. This enhances error reporting and handling for commit operations. --- app/src/lib/git-error-context.ts | 6 +++ app/src/lib/stores/app-store.ts | 71 +++++++++++++++++--------------- app/src/ui/app-error.tsx | 2 + 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/app/src/lib/git-error-context.ts b/app/src/lib/git-error-context.ts index f6e7a376000..c44b641e54b 100644 --- a/app/src/lib/git-error-context.ts +++ b/app/src/lib/git-error-context.ts @@ -23,8 +23,14 @@ type CreateRepositoryErrorContext = { readonly kind: 'create-repository' } +type CommitErrorContext = { + /** The Git operation that triggered the error */ + readonly kind: 'commit' +} + /** A custom shape of data for actions to provide to help with error handling */ export type GitErrorContext = | MergeOrPullConflictsErrorContext | CheckoutBranchErrorContext | CreateRepositoryErrorContext + | CommitErrorContext diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 7083bc23db2..56623fc4969 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -3307,40 +3307,43 @@ export class AppStore extends TypedBaseStore { const gitStore = this.gitStoreCache.get(repository) return this.withIsCommitting(repository, async () => { - const result = await gitStore.performFailableOperation(async () => { - const message = await formatCommitMessage(repository, context) - let aborted = false - return createCommit(repository, message, selectedFiles, { - amend: context.amend, - onHookProgress: hookProgress => { - this.repositoryStateCache.update(repository, state => ({ - ...state, - hookProgress, - })) - this.emitUpdate() - }, - onHookFailure: (hookName, terminalOutput) => - new Promise(resolve => { - this._showPopup({ - type: PopupType.HookFailed, - hookName, - terminalOutput, - resolve: resolution => { - if (resolution === 'abort') { - aborted = true - } - resolve(resolution) - }, - }) - }), - onTerminalOutputAvailable: subscribeToCommitOutput => { - this.repositoryStateCache.update(repository, state => ({ - ...state, - subscribeToCommitOutput, - })) - }, - }).catch(err => (aborted ? undefined : Promise.reject(err))) - }) + const result = await gitStore.performFailableOperation( + async () => { + const message = await formatCommitMessage(repository, context) + let aborted = false + return createCommit(repository, message, selectedFiles, { + amend: context.amend, + onHookProgress: hookProgress => { + this.repositoryStateCache.update(repository, state => ({ + ...state, + hookProgress, + })) + this.emitUpdate() + }, + onHookFailure: (hookName, terminalOutput) => + new Promise(resolve => { + this._showPopup({ + type: PopupType.HookFailed, + hookName, + terminalOutput, + resolve: resolution => { + if (resolution === 'abort') { + aborted = true + } + resolve(resolution) + }, + }) + }), + onTerminalOutputAvailable: subscribeToCommitOutput => { + this.repositoryStateCache.update(repository, state => ({ + ...state, + subscribeToCommitOutput, + })) + }, + }).catch(err => (aborted ? undefined : Promise.reject(err))) + }, + { gitContext: { kind: 'commit' }, repository } + ) if (result !== undefined) { await this._recordCommitStats( diff --git a/app/src/ui/app-error.tsx b/app/src/ui/app-error.tsx index 10dc4301933..b3cbbfd521d 100644 --- a/app/src/ui/app-error.tsx +++ b/app/src/ui/app-error.tsx @@ -164,6 +164,8 @@ export class AppError extends React.Component { switch (gitContext?.kind) { case 'create-repository': return `Failed creating repository` + case 'commit': + return `Commit failed` } } From 65996f62af205ae0c20060003ea32ba598359881 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 18:55:50 +0100 Subject: [PATCH 124/865] Refactor hook failure terminal to StaticTerminal component Replaces direct xterm usage in HookFailed with a new StaticTerminal component for improved encapsulation and reusability. Adds static-terminal.tsx to handle terminal rendering and output display, simplifying HookFailed and centralizing terminal logic. --- app/src/ui/get-monospace-font-family.ts | 6 +++ app/src/ui/hook-failed/hook-failed.tsx | 32 +++---------- app/src/ui/static-terminal.tsx | 60 +++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 app/src/ui/get-monospace-font-family.ts create mode 100644 app/src/ui/static-terminal.tsx diff --git a/app/src/ui/get-monospace-font-family.ts b/app/src/ui/get-monospace-font-family.ts new file mode 100644 index 00000000000..11cd2cc82ba --- /dev/null +++ b/app/src/ui/get-monospace-font-family.ts @@ -0,0 +1,6 @@ +export const getMonospaceFontFamily = (): string => { + // TODO: This is the same as the --font-family-monospace defined in + // variables.scss but we could be more clever here and only pick + // platform-specific fonts. Not sure if it matters. + return "SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace, 'Apple Color Emoji', 'Segoe UI', 'Segoe UI Emoji', 'Segoe UI Symbol'" +} diff --git a/app/src/ui/hook-failed/hook-failed.tsx b/app/src/ui/hook-failed/hook-failed.tsx index 80e5620b24e..617039ef751 100644 --- a/app/src/ui/hook-failed/hook-failed.tsx +++ b/app/src/ui/hook-failed/hook-failed.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Dialog, DialogContent, DialogFooter } from '../dialog' import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' -import { Terminal } from '@xterm/xterm' +import { StaticTerminal } from '../static-terminal' interface IHookFailedProps { readonly hookName: string @@ -13,9 +13,6 @@ interface IHookFailedProps { /** A component to confirm and then discard changes. */ export class HookFailed extends React.Component { - private terminalRef = React.createRef() - private terminal: Terminal | null = null - private getDialogTitle() { return `${this.props.hookName} ${__DARWIN__ ? 'Failed' : 'failed'}` } @@ -30,27 +27,6 @@ export class HookFailed extends React.Component { this.props.onDismissed() } - public componentDidMount(): void { - if (this.terminalRef.current) { - this.terminal = new Terminal({ - disableStdin: true, - convertEol: true, - rows: 12, - cols: 80, - fontSize: 12, - fontFamily: - "SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace, 'Apple Color Emoji', 'Segoe UI', 'Segoe UI Emoji', 'Segoe UI Symbol'", - }) - this.terminal.open(this.terminalRef.current) - this.terminal.write(this.props.terminalOutput) - } - } - - public componentWillUnmount(): void { - this.terminal?.dispose() - this.terminal = null - } - public render() { return ( {

The {this.props.hookName} hook failed. What would you like to do?

-
+ diff --git a/app/src/ui/static-terminal.tsx b/app/src/ui/static-terminal.tsx new file mode 100644 index 00000000000..4a0d1c61c93 --- /dev/null +++ b/app/src/ui/static-terminal.tsx @@ -0,0 +1,60 @@ +import { + ITerminalOptions, + ITerminalInitOnlyOptions, + Terminal, +} from '@xterm/xterm' +import React from 'react' +import { getMonospaceFontFamily } from './get-monospace-font-family' + +export const defaultTerminalOptions: Readonly = { + convertEol: true, + fontFamily: getMonospaceFontFamily(), + fontSize: 12, + screenReaderMode: true, +} + +export type StaticTerminalProps = ITerminalOptions & + ITerminalInitOnlyOptions & { + readonly terminalOutput: string + readonly hideCursor?: boolean + } + +export class StaticTerminal extends React.Component { + private terminalRef = React.createRef() + private terminal: Terminal | null = null + + public componentDidMount() { + const { terminalOutput, ...initOpts } = this.props + this.terminal = new Terminal({ + ...defaultTerminalOptions, + ...initOpts, + + rows: this.props.rows ?? 20, + cols: this.props.cols ?? 80, + }) + + this.terminal.onKey(({ key, domEvent }) => { + if (domEvent.key === 'ArrowUp' || domEvent.key === 'ArrowDown') { + this.terminal?.scrollLines(domEvent.key === 'ArrowUp' ? -1 : 1) + return + } + }) + + if (this.terminalRef.current) { + this.terminal.open(this.terminalRef.current) + + if (this.terminal.textarea) { + this.terminal.textarea.disabled = true + } + + if (this.props.hideCursor !== false) { + this.terminal.write('\x1b[?25l') // hide cursor + this.terminal.write(terminalOutput.trimEnd()) + } + } + } + + public render() { + return
+ } +} From df821789e8004fdc61aa01a282af518a07d25f31 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 18:56:01 +0100 Subject: [PATCH 125/865] Refactor terminal options in CommitProgress Replaces inline terminal configuration with defaultTerminalOptions from static-terminal for consistency and maintainability. --- app/src/ui/commit-progress/commit-progress.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/ui/commit-progress/commit-progress.tsx b/app/src/ui/commit-progress/commit-progress.tsx index 2dae760c37d..00cf528e82a 100644 --- a/app/src/ui/commit-progress/commit-progress.tsx +++ b/app/src/ui/commit-progress/commit-progress.tsx @@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogFooter } from '../dialog' import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' import { TerminalOutputListener } from '../../lib/git' import { Terminal } from '@xterm/xterm' +import { defaultTerminalOptions } from '../static-terminal' interface ICommitProgressProps { readonly subscribeToCommitOutput: TerminalOutputListener @@ -25,13 +26,9 @@ export class CommitProgress extends React.Component { public componentDidMount() { if (this.terminalRef.current) { this.terminal = new Terminal({ - disableStdin: true, - convertEol: true, + ...defaultTerminalOptions, rows: 20, cols: 80, - fontSize: 12, - fontFamily: - "SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace, 'Apple Color Emoji', 'Segoe UI', 'Segoe UI Emoji', 'Segoe UI Symbol'", }) this.terminal.open(this.terminalRef.current) From 367bf24f49539e22d8dcdef960a68cbb568e031d Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 18:56:10 +0100 Subject: [PATCH 126/865] Remove commented-out commit progress UI code --- app/src/ui/changes/commit-message.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index 7cf60fa016f..c4cdc24b5fc 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -1554,14 +1554,6 @@ export class CommitMessage extends React.Component< const { isCommitting, hookProgress } = this.props if (!isCommitting || !hookProgress) { return null - // return ( - //
- //
Optimizing repository...
- // - //
- // ) } const { status, hookName } = hookProgress From 6e91b7883bc9e5e37aee29b605454f0d7c37a236 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 18:56:29 +0100 Subject: [PATCH 127/865] Improve raw git error display in dialogs Replaces monospace paragraph with StaticTerminal for raw git errors in AppError component, providing better formatting. Increases max-width of raw git error dialogs and adds padding to static terminal output for improved readability. --- app/src/ui/app-error.tsx | 3 ++- app/styles/ui/_dialog.scss | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/ui/app-error.tsx b/app/src/ui/app-error.tsx index b3cbbfd521d..6acaf2ccc85 100644 --- a/app/src/ui/app-error.tsx +++ b/app/src/ui/app-error.tsx @@ -17,6 +17,7 @@ import { GitError as DugiteError } from 'dugite' import { LinkButton } from './lib/link-button' import { getFileFromExceedsError } from '../lib/helpers/regex' import { CopilotError } from '../lib/copilot-error' +import { StaticTerminal } from './static-terminal' interface IAppErrorProps { /** The error to be displayed */ @@ -95,7 +96,7 @@ export class AppError extends React.Component { // If the error message is just the raw git output, display it in // fixed-width font if (isRawGitError(e)) { - return

{e.message}

+ return } if ( diff --git a/app/styles/ui/_dialog.scss b/app/styles/ui/_dialog.scss index 469d08dc402..79e534bead8 100644 --- a/app/styles/ui/_dialog.scss +++ b/app/styles/ui/_dialog.scss @@ -356,7 +356,11 @@ dialog { // Use wider dialogs for raw git errors, to make sure we can display lines // up to 80 characters long without wrapping them. &.raw-git-error { - max-width: 700px; + max-width: 750px; + + .static-terminal .xterm { + padding: var(--spacing); + } } .dialog-content { From 83310746b9489198cec3f366e0a29d00934ff3ae Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 18:56:42 +0100 Subject: [PATCH 128/865] Refactor terminal output handling in git core Replaces usage of createTailStream and createTerminalStream with a custom buffer management for terminal output. This change simplifies the logic for capturing and limiting combined stdout and stderr output to 256kb, improving maintainability and performance. --- app/src/lib/git/core.ts | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 7bd9da26f9f..45fac74658f 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -13,8 +13,6 @@ import * as GitPerf from '../../ui/lib/git-perf' import * as Path from 'path' import { isErrnoException } from '../errno-exception' import { withTrampolineEnv } from '../trampoline/trampoline-environment' -import { createTailStream } from './create-tail-stream' -import { createTerminalStream } from '../create-terminal-stream' import { kStringMaxLength } from 'buffer' import { withHooksEnv } from '../hooks/with-hooks-env' @@ -248,8 +246,8 @@ export async function git( // Note: The output is capped at a maximum of 256kb and the sole intent of // this property is to provide "terminal-like" output to the user when a Git // command fails. - let terminalOutput = '' const terminalChunks: string[] = [] + let terminalOutputLength = 0 // Keep at most 256kb of combined stderr and stdout output. This is used // to provide more context in error messages. @@ -270,21 +268,28 @@ export async function git( }) } - const terminalStream = createTerminalStream() - const tailStream = createTailStream(256 * 1024, { encoding: 'utf8' }) - - terminalStream - .pipe(tailStream) - .on('data', (data: string) => (terminalOutput = data)) - .on('error', e => log.error(`Terminal output error`, e)) - - process.stdout?.pipe(terminalStream, { end: false }) - process.stderr?.pipe(terminalStream, { end: false }) + const capacity = 256 * 1024 + const push = (chunk: Buffer | string) => { + terminalChunks.push(coerceToString(chunk)) + terminalOutputLength += chunk.length + + while (terminalOutputLength > capacity) { + const firstChunk = terminalChunks[0] + const overrun = terminalOutputLength - capacity + + if (overrun >= firstChunk.length) { + terminalChunks.shift() + terminalOutputLength -= firstChunk.length + } else { + terminalChunks[0] = firstChunk.substring(overrun) + terminalOutputLength -= overrun + } + } + } - process.stdout?.on('data', chunk => terminalChunks.push(chunk)) - process.stderr?.on('data', chunk => terminalChunks.push(chunk)) + process.stdout?.on('data', push) + process.stderr?.on('data', push) - process.on('close', () => terminalStream.end()) options?.processCallback?.(process) } @@ -375,6 +380,8 @@ export async function git( )}\` exited with an unexpected code: ${exitCode}.` ) + const terminalOutput = terminalChunks.join('') + if (terminalOutput.length > 0) { // Leave even less of the combined output in the log errorMessage.push(terminalOutput.slice(-1024)) From ea584af54c6c8fa54c57ec1f8e004c2ee06138ea Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 18:56:46 +0100 Subject: [PATCH 129/865] Remove console logging from hooks stderr handler Eliminated the console.log statement in the child.stderr 'data' event handler to prevent unnecessary logging of hook stderr output. This change keeps terminal output collection without printing to the console. --- app/src/lib/hooks/hooks-proxy.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index b3c00f0bcaa..8700823dbd3 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -162,10 +162,7 @@ export const createHooksProxy = ( // hooks never write to stdout // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 child.stderr.pipe(conn.stderr, { end: false }).on('error', reject) - child.stderr.on('data', data => { - terminalOutput.push(data) - console.log('hooks stderr:', data.toString()) - }) + child.stderr.on('data', data => terminalOutput.push(data)) }) if (signal !== null) { From f1e6fc40e38a1f282c76ba351c237b10aa6b98b2 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 19:35:22 +0100 Subject: [PATCH 130/865] Refactor StaticTerminal to Terminal and update usage Renamed StaticTerminal to Terminal and updated all references in the UI components. Refactored Terminal to support both static output and dynamic writing, simplifying terminal integration and improving code maintainability. --- app/src/ui/app-error.tsx | 4 +- .../ui/commit-progress/commit-progress.tsx | 32 ++++++---------- app/src/ui/hook-failed/hook-failed.tsx | 4 +- .../ui/{static-terminal.tsx => terminal.tsx} | 37 ++++++++++--------- app/styles/ui/_dialog.scss | 2 +- 5 files changed, 36 insertions(+), 43 deletions(-) rename app/src/ui/{static-terminal.tsx => terminal.tsx} (56%) diff --git a/app/src/ui/app-error.tsx b/app/src/ui/app-error.tsx index 6acaf2ccc85..089e645caea 100644 --- a/app/src/ui/app-error.tsx +++ b/app/src/ui/app-error.tsx @@ -17,7 +17,7 @@ import { GitError as DugiteError } from 'dugite' import { LinkButton } from './lib/link-button' import { getFileFromExceedsError } from '../lib/helpers/regex' import { CopilotError } from '../lib/copilot-error' -import { StaticTerminal } from './static-terminal' +import { Terminal } from './terminal' interface IAppErrorProps { /** The error to be displayed */ @@ -96,7 +96,7 @@ export class AppError extends React.Component { // If the error message is just the raw git output, display it in // fixed-width font if (isRawGitError(e)) { - return + return } if ( diff --git a/app/src/ui/commit-progress/commit-progress.tsx b/app/src/ui/commit-progress/commit-progress.tsx index 00cf528e82a..19542972460 100644 --- a/app/src/ui/commit-progress/commit-progress.tsx +++ b/app/src/ui/commit-progress/commit-progress.tsx @@ -3,9 +3,7 @@ import * as React from 'react' import { Dialog, DialogContent, DialogFooter } from '../dialog' import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' import { TerminalOutputListener } from '../../lib/git' -import { Terminal } from '@xterm/xterm' -import { defaultTerminalOptions } from '../static-terminal' - +import { Terminal } from '../terminal' interface ICommitProgressProps { readonly subscribeToCommitOutput: TerminalOutputListener readonly onDismissed: () => void @@ -14,8 +12,7 @@ interface ICommitProgressProps { /** A component to confirm and then discard changes. */ export class CommitProgress extends React.Component { private unsubscribe?: () => void | null - private terminalRef = React.createRef() - private terminal: Terminal | null = null + private terminalRef = React.createRef() private onDismissed = () => { this.unsubscribe?.() @@ -24,19 +21,9 @@ export class CommitProgress extends React.Component { } public componentDidMount() { - if (this.terminalRef.current) { - this.terminal = new Terminal({ - ...defaultTerminalOptions, - rows: 20, - cols: 80, - }) - - this.terminal.open(this.terminalRef.current) - } - - const { unsubscribe } = this.props.subscribeToCommitOutput(chunk => { - this.terminal?.write(chunk) - }) + const { unsubscribe } = this.props.subscribeToCommitOutput(chunk => + this.terminalRef.current?.write(chunk) + ) this.unsubscribe = unsubscribe } @@ -44,8 +31,6 @@ export class CommitProgress extends React.Component { public componentWillUnmount() { this.unsubscribe?.() this.unsubscribe = undefined - this.terminal?.dispose() - this.terminal = null } public render() { @@ -57,7 +42,12 @@ export class CommitProgress extends React.Component { onSubmit={this.onDismissed} > -
+
diff --git a/app/src/ui/hook-failed/hook-failed.tsx b/app/src/ui/hook-failed/hook-failed.tsx index 617039ef751..f8d1f8ba1a1 100644 --- a/app/src/ui/hook-failed/hook-failed.tsx +++ b/app/src/ui/hook-failed/hook-failed.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Dialog, DialogContent, DialogFooter } from '../dialog' import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' -import { StaticTerminal } from '../static-terminal' +import { Terminal } from '../terminal' interface IHookFailedProps { readonly hookName: string @@ -42,7 +42,7 @@ export class HookFailed extends React.Component {

The {this.props.hookName} hook failed. What would you like to do?

- = { screenReaderMode: true, } -export type StaticTerminalProps = ITerminalOptions & +export type TerminalProps = ITerminalOptions & ITerminalInitOnlyOptions & { - readonly terminalOutput: string + readonly terminalOutput?: string readonly hideCursor?: boolean } -export class StaticTerminal extends React.Component { +export class Terminal extends React.Component { private terminalRef = React.createRef() - private terminal: Terminal | null = null + private terminal: XTermTerminal | null = null + + public get Terminal() { + return this.terminal + } + + public write(data: string | Buffer) { + this.terminal?.write(data) + } public componentDidMount() { - const { terminalOutput, ...initOpts } = this.props - this.terminal = new Terminal({ + const { terminalOutput, hideCursor, ...initOpts } = this.props + this.terminal = new XTermTerminal({ ...defaultTerminalOptions, ...initOpts, @@ -33,13 +41,6 @@ export class StaticTerminal extends React.Component { cols: this.props.cols ?? 80, }) - this.terminal.onKey(({ key, domEvent }) => { - if (domEvent.key === 'ArrowUp' || domEvent.key === 'ArrowDown') { - this.terminal?.scrollLines(domEvent.key === 'ArrowUp' ? -1 : 1) - return - } - }) - if (this.terminalRef.current) { this.terminal.open(this.terminalRef.current) @@ -47,14 +48,16 @@ export class StaticTerminal extends React.Component { this.terminal.textarea.disabled = true } - if (this.props.hideCursor !== false) { + if (hideCursor !== false) { this.terminal.write('\x1b[?25l') // hide cursor - this.terminal.write(terminalOutput.trimEnd()) + if (terminalOutput) { + this.terminal.write(terminalOutput.trimEnd()) + } } } } public render() { - return
+ return
} } diff --git a/app/styles/ui/_dialog.scss b/app/styles/ui/_dialog.scss index 79e534bead8..cc931bd817d 100644 --- a/app/styles/ui/_dialog.scss +++ b/app/styles/ui/_dialog.scss @@ -358,7 +358,7 @@ dialog { &.raw-git-error { max-width: 750px; - .static-terminal .xterm { + .xterm { padding: var(--spacing); } } From 9ba48b609515787887604312ffb9e61a55725be6 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 6 Nov 2025 19:40:01 +0100 Subject: [PATCH 131/865] Avoid aria warning due to path descendant of svg being focusable --- app/styles/ui/changes/_commit-message.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/styles/ui/changes/_commit-message.scss b/app/styles/ui/changes/_commit-message.scss index 40a9f6e94dc..98df2973647 100644 --- a/app/styles/ui/changes/_commit-message.scss +++ b/app/styles/ui/changes/_commit-message.scss @@ -42,6 +42,7 @@ padding: 0 var(--spacing-half); > svg.octicon { height: 12px; + pointer-events: none; } &:hover { From 619be5d1ab2a6dff6c4122eb5de20514d99234c3 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 7 Nov 2025 09:21:21 +0000 Subject: [PATCH 132/865] Remove duplicate process-proxy binary copy step --- script/build.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/script/build.ts b/script/build.ts index 5200852787b..bbc4628a555 100755 --- a/script/build.ts +++ b/script/build.ts @@ -376,15 +376,6 @@ function copyDependencies() { ) ) - console.log(' Copying process-proxy binary') - copySync( - getProxyCommandPath(), - path.resolve( - outRoot, - process.platform === 'win32' ? 'process-proxy.exe' : 'process-proxy' - ) - ) - console.log(' Copying printenvz binary') copySync( getPrintenvzPath(), From 0f03a1a9b3818a744a9ee8611cef8eabd0bb7ec5 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 7 Nov 2025 14:04:32 +0000 Subject: [PATCH 133/865] Initial GitBash support Replaces execFile with spawn in get-shell-env for improved control over process execution and output handling. Refactors get-shell to support async shell detection and custom quoting for Windows and Unix shells, moving shell quoting logic to a new shell-escape module. This improves cross-platform compatibility and quoting reliability for shell commands. --- app/src/lib/hooks/get-shell-env.ts | 39 ++++++++++--- app/src/lib/hooks/get-shell.ts | 92 +++++++++++++++++++++++------- app/src/lib/hooks/shell-escape.ts | 42 ++++++++++++++ 3 files changed, 144 insertions(+), 29 deletions(-) create mode 100644 app/src/lib/hooks/shell-escape.ts diff --git a/app/src/lib/hooks/get-shell-env.ts b/app/src/lib/hooks/get-shell-env.ts index 90f19d4c558..fedade6d3ef 100644 --- a/app/src/lib/hooks/get-shell-env.ts +++ b/app/src/lib/hooks/get-shell-env.ts @@ -1,6 +1,6 @@ import { join } from 'path' import { getShell } from './get-shell' -import { execFile } from '../exec-file' +import { spawn } from 'child_process' export const getShellEnv = async (): Promise< Record @@ -8,12 +8,35 @@ export const getShellEnv = async (): Promise< const ext = __WIN32__ ? '.exe' : '' const printenvzPath = join(__dirname, `printenvz${ext}`) - const { shell, args, quote } = getShell() - const { stdout } = await execFile(shell, [...args, quote(printenvzPath)], { - env: {}, - maxBuffer: Infinity, - }) + const { shell, args, quoteCommand, windowsVerbatimArguments, argv0 } = + await getShell() + + return await new Promise((resolve, reject) => { + const child = spawn(shell, [...args, quoteCommand(printenvzPath)], { + env: {}, + windowsVerbatimArguments, + argv0, + stdio: 'pipe', + }) + + const chunks: Buffer[] = [] - const matches = stdout.matchAll(/([^=]+)=([^\0]*)\0/g) - return Object.fromEntries(Array.from(matches, m => [m[1], m[2]])) + child.stdout + .on('data', chunk => chunks.push(chunk)) + .on('end', () => { + const stdout = Buffer.concat(chunks).toString('utf8') + const matches = stdout.matchAll(/([^=]+)=([^\0]*)\0/g) + resolve(Object.fromEntries(Array.from(matches, m => [m[1], m[2]]))) + }) + + child.on('error', err => reject(err)) + + child.on('close', (code, signal) => { + if (code !== 0) { + return reject( + new Error(`child exited with code ${code} and signal ${signal}`) + ) + } + }) + }) } diff --git a/app/src/lib/hooks/get-shell.ts b/app/src/lib/hooks/get-shell.ts index ebf2ba3a872..29f0bb77a14 100644 --- a/app/src/lib/hooks/get-shell.ts +++ b/app/src/lib/hooks/get-shell.ts @@ -1,31 +1,81 @@ -import memoizeOne from 'memoize-one' -import { Shescape } from 'shescape' - -const getQuoteFn = memoizeOne((shell: string) => { - const shescape = new Shescape({ shell, flagProtection: false }) - return { - escape: shescape.escape.bind(shescape), - quote: shescape.quote.bind(shescape), +import { pathExists } from 'fs-extra' +import { join } from 'path' +import which from 'which' +import { bash, cmd } from './shell-escape' + +type Shell = { + shell: string + args: string[] + quoteCommand: (cmd: string, ...args: string[]) => string + windowsVerbatimArguments?: boolean + argv0?: string +} + +export const findGitBash = async () => { + const gitPath = await which('git', { nothrow: true }) + + if (!gitPath) { + return null } -}) -export const getShell = () => { - // TODO: Windows: - if (__WIN32__) { - throw new Error('Not implemented') + if (!gitPath.toLowerCase().endsWith('\\cmd\\git.exe')) { + return null } - if (process.env.SHELL) { + const bashPath = join(gitPath, '../../usr/bin/bash.exe') + return (await pathExists(bashPath)) ? bashPath : null +} + +// // https://github.com/git-for-windows/git/blob/bd2ecbae58213046a468256b95fc4864de25bdf5/compat/mingw.c#L1690-L1718 +// const quoteArgMsys2 = (arg: string) => { +// return /[\s\\"'{?*~]/.test(arg) ? `"${arg.replace(/(["\\])/g, '\\$1')}"` : arg +// } + +const findWindowsShell = async (): Promise => { + const gitBashPath = await findGitBash() + + if (gitBashPath) { + const { args, quoteCommand } = bash return { - shell: process.env.SHELL, - args: ['-ilc'], - ...getQuoteFn(process.env.SHELL), + shell: gitBashPath, + args, + quoteCommand: (cmd, ...args) => `"${quoteCommand(cmd, ...args)}"`, + // MSYS2 doesn't use the argv it's given, instead it re-parses the + // commandline from GetCommandLineW and it doesn't comform to the + // usual Windows quoting rules. So we need to opt out of Node.js's + // quoting behavior and do it ourselves. + // + // See https://github.com/git-for-windows/git/commit/9e9da23c27650 + windowsVerbatimArguments: true, + // With windowsVerbatimArguments set to true the filename passed to + // spawn won't get quoted by Node.js so he msys2 custom argument parser + // will blow up so we'll just hardcode argv[0] as bash.exe which is + // what it would be set to if a user ran bash.exe in a terminal and it + // was on PATH. The technically correct way would be to set quote it + // as msys2 expects it to be quoted but I'm too deep into Dantes nine + // circles of quoting already. + argv0: 'bash.exe', } } - return { - shell: '/bin/sh', - args: ['-ilc'], - ...getQuoteFn('/bin/sh'), + const { COMSPEC } = process.env + // https://github.com/nodejs/node/blob/5f77aebdfb3ea4d60cda79045d29afb244d6bcb1/lib/child_process.js#L660C31-L660C58 + const shell = + COMSPEC && /^(?:.*\\)?cmd(?:\.exe)?$/i.test(COMSPEC) ? COMSPEC : 'cmd.exe' + const { args, quoteCommand } = cmd + return { shell, args, quoteCommand } +} + +export const getShell = async (): Promise => { + if (__WIN32__) { + return findWindowsShell() } + + // For our purposes quoting using bash rules should be sufficient, + // we only need to pass a path to an executable that we control. + // Should we start using this to quote commands that Git gives us + // those are quite innocuous as well (like shas and paths). There + // shouldn't be any user input in there. + const { args, quoteCommand } = bash + return { shell: process.env.SHELL ?? '/bin/sh', args, quoteCommand } } diff --git a/app/src/lib/hooks/shell-escape.ts b/app/src/lib/hooks/shell-escape.ts new file mode 100644 index 00000000000..237ba49a2d0 --- /dev/null +++ b/app/src/lib/hooks/shell-escape.ts @@ -0,0 +1,42 @@ +type Shell = { + args: string[] + quoteCommand: (cmd: string, ...args: string[]) => string +} + +// https://github.com/ericcornelissen/shescape/blob/89072ba7de233f81f5553b52098671c94eb9bd0c/src/internal/unix/bash.js#L39 +const bashEscape = (arg: string) => arg + .replace(/[\0\u0008\u001B\u009B]/gu, "") + .replace(/\r(?!\n)/gu, "") + .replace(/'/gu, "'\\''"); + +const shQuoteCommand = (escapeFn: (arg: string) => string, cmd: string, ...args: string[]) => + [cmd, ...args].map(a => `'${escapeFn(a)}'`).join(' '); + +export const bash: Shell = { + args: ['-ilc'], + quoteCommand: shQuoteCommand.bind(null, bashEscape), +} + +// https://github.com/ericcornelissen/shescape/blob/89072ba7de233f81f5553b52098671c94eb9bd0c/src/internal/unix/zsh.js#L37 +// At time of writing zsh escapeArgForQuoted was identical to bash's +const zshEscape = bashEscape + +export const zsh: Shell = { + args: ['-ilc'], + quoteCommand: shQuoteCommand.bind(null, zshEscape), +} + +// https://github.com/ericcornelissen/shescape/blob/89072ba7de233f81f5553b52098671c94eb9bd0c/src/internal/win/cmd.js#L35 +const cmdEscape = (arg: string) => arg + .replace(/[\0\u0008\r\u001B\u009B]/gu, "") + .replace(/\n/gu, " ") + .replace(/"/gu, '""') + .replace(/([%&<>^|])/gu, '"^$1"') + .replace(/(? + `"${[cmd, ...args].map(a => `"${cmdEscape(a)}"`).join(' ')}"` + , +} From a3137f7c170da171f9837aaf790c08219bc40ee3 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 07:59:29 +0000 Subject: [PATCH 134/865] Enable MSYS2 argument quoting for Git Bash shell Uncommented and applied the MSYS2 argument quoting function to ensure proper command quoting for Git Bash on Windows. This change improves compatibility with MSYS2's command line parsing. --- app/src/lib/hooks/get-shell.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/lib/hooks/get-shell.ts b/app/src/lib/hooks/get-shell.ts index 29f0bb77a14..ad3b95f9a8d 100644 --- a/app/src/lib/hooks/get-shell.ts +++ b/app/src/lib/hooks/get-shell.ts @@ -26,10 +26,10 @@ export const findGitBash = async () => { return (await pathExists(bashPath)) ? bashPath : null } -// // https://github.com/git-for-windows/git/blob/bd2ecbae58213046a468256b95fc4864de25bdf5/compat/mingw.c#L1690-L1718 -// const quoteArgMsys2 = (arg: string) => { -// return /[\s\\"'{?*~]/.test(arg) ? `"${arg.replace(/(["\\])/g, '\\$1')}"` : arg -// } +// https://github.com/git-for-windows/git/blob/bd2ecbae58213046a468256b95fc4864de25bdf5/compat/mingw.c#L1690-L1718 +const quoteArgMsys2 = (arg: string) => { + return /[\s\\"'{?*~]/.test(arg) ? `"${arg.replace(/(["\\])/g, '\\$1')}"` : arg +} const findWindowsShell = async (): Promise => { const gitBashPath = await findGitBash() @@ -39,7 +39,7 @@ const findWindowsShell = async (): Promise => { return { shell: gitBashPath, args, - quoteCommand: (cmd, ...args) => `"${quoteCommand(cmd, ...args)}"`, + quoteCommand: (cmd, ...args) => quoteArgMsys2(quoteCommand(cmd, ...args)), // MSYS2 doesn't use the argv it's given, instead it re-parses the // commandline from GetCommandLineW and it doesn't comform to the // usual Windows quoting rules. So we need to opt out of Node.js's From b3c987cb5f22e15069fa6f55038b2071fc5e6a88 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 07:59:51 +0000 Subject: [PATCH 135/865] Auto-approve garbage collection if pre-auto-gc hook missing Adds logic to automatically approve garbage collection when the 'pre-auto-gc' hook is not found in the repository, providing a debug message and exiting with a success code. This prevents unnecessary errors when the hook is absent. --- app/src/lib/hooks/hooks-proxy.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 8700823dbd3..8f1834b5c21 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -109,6 +109,16 @@ export const createHooksProxy = ( : undefined) if (!hooksExecutable) { + if (hookName === 'pre-auto-gc') { + debug(`no pre-auto-gc hook found, auto-approving garbage collection`) + await exitWithMessage( + conn, + `No pre-auto-gc hook found in repository, auto-approving garbage collection.`, + 0 + ) + return + } + debug(`hook executable not found for ${hookName}`) await exitWithError( conn, From 85ecb11dc6a3ec303f5837ebf9ca4457cd1e0c50 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 08:25:03 +0000 Subject: [PATCH 136/865] Improve commit progress UI with button styling Adds conditional styling for the commit progress component when the 'Show commit progress' button is present. Updates SCSS to adjust border and layout for better visual integration of the button. --- app/src/ui/changes/commit-message.tsx | 14 +++++++------- app/styles/ui/changes/_commit-message.scss | 20 ++++++++++++++------ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index c4cdc24b5fc..d216a34d152 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -1551,7 +1551,7 @@ export class CommitMessage extends React.Component< } private renderCommitProgress() { - const { isCommitting, hookProgress } = this.props + const { isCommitting, hookProgress, onShowCommitProgress } = this.props if (!isCommitting || !hookProgress) { return null } @@ -1569,14 +1569,14 @@ export class CommitMessage extends React.Component< ? `${hookName} hook failed` : assertNever(status, `Unknown hook status: ${status}`) + const cn = classNames('commit-progress', { + 'with-button': onShowCommitProgress !== undefined, + }) return ( -
+
{text}
- {this.props.onShowCommitProgress && ( - )} diff --git a/app/styles/ui/changes/_commit-message.scss b/app/styles/ui/changes/_commit-message.scss index 98df2973647..747aa9c7ab6 100644 --- a/app/styles/ui/changes/_commit-message.scss +++ b/app/styles/ui/changes/_commit-message.scss @@ -13,8 +13,6 @@ padding: var(--spacing); .commit-progress { - // color: var(--text-secondary-color); - display: flex; align-items: center; margin-top: var(--spacing-half); @@ -27,14 +25,24 @@ border: var(--base-border); border-radius: var(--button-border-radius); border-color: var(--secondary-button-border-color); - border-right: 0; - border-top-right-radius: 0; - border-bottom-right-radius: 0; + } + + &.with-button { + .description { + border-right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } } button { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + padding: 0; + flex-shrink: 0; - padding: var(--spacing-third); height: 100%; border-left-width: 1px; border-top-left-radius: 0; From 92d83c2300281d254c6c0bc813fa5a71164a4c86 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 09:31:41 +0100 Subject: [PATCH 137/865] Refactor git binary resolution in hooks proxy Moved git binary resolution from with-hooks-env.ts to hooks-proxy.ts, simplifying the function signatures and centralizing the logic. This change improves maintainability and reduces redundant code. --- app/src/lib/hooks/hooks-proxy.ts | 5 +++-- app/src/lib/hooks/with-hooks-env.ts | 9 +-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 8f1834b5c21..25de293a016 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -1,10 +1,11 @@ import { spawn } from 'child_process' import { randomBytes } from 'crypto' import { createWriteStream } from 'fs' -import { basename, join } from 'path' +import { basename, join, resolve } from 'path' import { ProcessProxyConnection } from 'process-proxy' import { pipeline } from 'stream/promises' import type { HookProgress } from '../git' +import { resolveGitBinary } from 'dugite' const hooksUsingStdin = ['post-rewrite'] const ignoredOnFailureHooks = [ @@ -51,7 +52,6 @@ const exitWithError = ( export const createHooksProxy = ( repoHooks: string[], tmpDir: string, - gitPath: string, shellEnv: Record, onHookProgress?: (progress: HookProgress) => void, onHookFailure?: ( @@ -151,6 +151,7 @@ export const createHooksProxy = ( ] const terminalOutput: Buffer[] = [] + const gitPath = resolveGitBinary(resolve(__dirname, 'git')) const { code, signal } = await new Promise<{ code: number | null diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index a2383ed06f5..488f44be050 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -1,14 +1,13 @@ import { cp, mkdtemp, rm } from 'fs/promises' import { AddressInfo } from 'net' import { tmpdir } from 'os' -import { basename, join, resolve } from 'path' +import { basename, join } from 'path' import { createProxyProcessServer } from 'process-proxy' import { enableHooksEnvironment } from '../feature-flag' import type { IGitExecutionOptions } from '../git/core' import { getRepoHooks } from './get-repo-hooks' import { createHooksProxy } from './hooks-proxy' import { getShellEnv } from './get-shell-env' -import { resolveGitBinary } from 'dugite' export async function withHooksEnv( fn: (env: Record | undefined) => Promise, @@ -38,11 +37,6 @@ export async function withHooksEnv( `hooks: loaded shell environment in ${Date.now() - shellEnvStartTime}ms` ) - // TODO: will throw - const gitPathStartTime = Date.now() - const gitPath = resolveGitBinary(resolve(__dirname, 'git')) - log.debug(`hooks: located git in ${Date.now() - gitPathStartTime}ms`) - const ext = __WIN32__ ? '.exe' : '' const processProxyPath = join(__dirname, `process-proxy${ext}`) @@ -51,7 +45,6 @@ export async function withHooksEnv( const hooksProxy = createHooksProxy( repoHooks, tmpHooksDir, - gitPath, shellEnv, options?.onHookProgress, options?.onHookFailure From 3d0c4f5bc0670479fafe91f9d6d4208e2bc52899 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 09:37:19 +0100 Subject: [PATCH 138/865] Memoize shell environment loading for hooks Replaces direct shell environment loading with a memoized async function in hooks-proxy and with-hooks-env. This optimizes repeated environment retrievals and improves performance when running multiple hooks. --- app/src/lib/hooks/hooks-proxy.ts | 3 ++- app/src/lib/hooks/with-hooks-env.ts | 16 ++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 25de293a016..b6fd8b26790 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -52,7 +52,7 @@ const exitWithError = ( export const createHooksProxy = ( repoHooks: string[], tmpDir: string, - shellEnv: Record, + getShellEnv: () => Promise>, onHookProgress?: (progress: HookProgress) => void, onHookFailure?: ( hookName: string, @@ -152,6 +152,7 @@ export const createHooksProxy = ( const terminalOutput: Buffer[] = [] const gitPath = resolveGitBinary(resolve(__dirname, 'git')) + const shellEnv = await getShellEnv() const { code, signal } = await new Promise<{ code: number | null diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index 488f44be050..8391203c039 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -8,6 +8,7 @@ import type { IGitExecutionOptions } from '../git/core' import { getRepoHooks } from './get-repo-hooks' import { createHooksProxy } from './hooks-proxy' import { getShellEnv } from './get-shell-env' +import memoizeOne from 'memoize-one' export async function withHooksEnv( fn: (env: Record | undefined) => Promise, @@ -31,11 +32,14 @@ export async function withHooksEnv( return fn(options?.env) } - const shellEnvStartTime = Date.now() - const shellEnv = await getShellEnv() - log.debug( - `hooks: loaded shell environment in ${Date.now() - shellEnvStartTime}ms` - ) + const memoizedGetShellEnv = memoizeOne(async () => { + const shellEnvStartTime = Date.now() + const shellEnv = await getShellEnv() + log.debug( + `hooks: loaded shell environment in ${Date.now() - shellEnvStartTime}ms` + ) + return shellEnv + }) const ext = __WIN32__ ? '.exe' : '' const processProxyPath = join(__dirname, `process-proxy${ext}`) @@ -45,7 +49,7 @@ export async function withHooksEnv( const hooksProxy = createHooksProxy( repoHooks, tmpHooksDir, - shellEnv, + memoizedGetShellEnv, options?.onHookProgress, options?.onHookFailure ) From ae03f5ff3ea7ce56ac4f075972809db106b91ee7 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 09:48:51 +0100 Subject: [PATCH 139/865] Refactor hook execution to use --ignore-missing for pre-auto-gc Removes manual check for pre-auto-gc hook existence and always runs the hook with --ignore-missing. This simplifies logic and ensures Git handles missing pre-auto-gc hooks gracefully, improving user messaging during garbage collection. --- app/src/lib/hooks/hooks-proxy.ts | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index b6fd8b26790..c16882bf5cd 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -102,31 +102,6 @@ export const createHooksProxy = ( ) ) - const hooksExecutable = - repoHooks.find(hook => hook.endsWith(hookName)) ?? - (__WIN32__ - ? repoHooks.find(hook => hook.endsWith(`${hookName}.exe`)) - : undefined) - - if (!hooksExecutable) { - if (hookName === 'pre-auto-gc') { - debug(`no pre-auto-gc hook found, auto-approving garbage collection`) - await exitWithMessage( - conn, - `No pre-auto-gc hook found in repository, auto-approving garbage collection.`, - 0 - ) - return - } - - debug(`hook executable not found for ${hookName}`) - await exitWithError( - conn, - `Error: hook executable not found for ${hookName}` - ) - return - } - // tmpdir is deleted when the Git call completes, so we can leave the file const stdinFilePath = join(tmpDir, `in-${randomBytes(8).toString('hex')}`) const hasStdin = hooksUsingStdin.includes(hookName) @@ -145,6 +120,12 @@ export const createHooksProxy = ( 'hook', 'run', hookName, + // We always copy our pre-auto-gc hook in order to be able to tell the + // user that the reason their commit is taking so long is because Git is + // performing garbage collection, but it's unlikely that the user has a + // pre-auto-gc hook configured themselves, so we tell Git to ignore + // missing hooks here. + ...(hookName === 'pre-auto-gc' ? ['--ignore-missing'] : []), ...(hasStdin ? ['--to-stdin', stdinFilePath] : []), '--', ...proxyArgs.slice(1), From 23fbd46c71c004592cd844110a931505c3cbcc79 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 14:17:13 +0100 Subject: [PATCH 140/865] Dispose terminal on component unmount Adds a componentWillUnmount lifecycle method to dispose of the XTermTerminal instance when the Terminal component is unmounted, ensuring proper cleanup of resources. --- app/src/ui/terminal.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/ui/terminal.tsx b/app/src/ui/terminal.tsx index 1e52332d0e4..e4138e0cbb8 100644 --- a/app/src/ui/terminal.tsx +++ b/app/src/ui/terminal.tsx @@ -31,6 +31,10 @@ export class Terminal extends React.Component { this.terminal?.write(data) } + public componentWillUnmount(): void { + this.terminal?.dispose() + } + public componentDidMount() { const { terminalOutput, hideCursor, ...initOpts } = this.props this.terminal = new XTermTerminal({ From 9aa520a6e827bcaaed97726b6376e450160423ed Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 14:18:24 +0100 Subject: [PATCH 141/865] Refactor excluded environment variables in hooks-proxy Moves the excluded environment variables to a top-level constant for reuse and clarity. Simplifies the hook progress callback by extracting the abort function. --- app/src/lib/hooks/hooks-proxy.ts | 42 +++++++++++++++----------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index c16882bf5cd..29bde888972 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -21,6 +21,23 @@ const ignoredOnFailureHooks = [ 'pre-auto-gc', ] +const excludedEnvVars: ReadonlySet = new Set([ + // Dugite sets these, we don't want to leak them into the hook environment + 'GIT_SYSTEM_CONFIG', + 'GIT_EXEC_PATH', + 'GIT_TEMPLATE_DIR', + // We set this to point to a custom hooks path which we don't want + // leaking into the hook's environment. Initially I thought we would have + // to sanitize this to strip out the custom config we set and leave any + // user-configured but since we're executing the hook in a separate + // shell with login it would just get re-initialized there anyway. + 'GIT_CONFIG_PARAMETERS', + + 'GIT_ASKPASS', + 'GIT_SSH_COMMAND', + 'GIT_USER_AGENT', +]) + const debug = (message: string, error?: Error) => { log.debug(`hooks: ${message}`, error) } @@ -70,31 +87,10 @@ export const createHooksProxy = ( : basename(proxyArgs[0]) const abortController = new AbortController() + const abort = () => abortController.abort() conn.stderr.write(`Running ${hookName} hook...\n`) - - onHookProgress?.({ - hookName, - status: 'started', - abort: () => abortController.abort(), - }) - - const excludedEnvVars = new Set([ - // Dugite sets these, we don't want to leak them into the hook environment - 'GIT_SYSTEM_CONFIG', - 'GIT_EXEC_PATH', - 'GIT_TEMPLATE_DIR', - // We set this to point to a custom hooks path which we don't want - // leaking into the hook's environment. Initially I thought we would have - // to sanitize this to strip out the custom config we set and leave any - // user-configured but since we're executing the hook in a separate - // shell with login it would just get re-initialized there anyway. - 'GIT_CONFIG_PARAMETERS', - - 'GIT_ASKPASS', - 'GIT_SSH_COMMAND', - 'GIT_USER_AGENT', - ]) + onHookProgress?.({ hookName, status: 'started', abort }) const safeEnv = Object.fromEntries( Object.entries(proxyEnv).filter( From 3a5bf4098529dbff7f3bbe1469a5caf20a29b329 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 14:18:37 +0100 Subject: [PATCH 142/865] Remove debug logging from hook process error handler Eliminates the debug log statement in the error handler for spawned hook processes, streamlining error handling by directly rejecting the promise. --- app/src/lib/hooks/hooks-proxy.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 29bde888972..b1988a52097 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -143,10 +143,7 @@ export const createHooksProxy = ( signal: abortController.signal, }) .on('close', (code, signal) => resolve({ code, signal })) - .on('error', err => { - debug(`failed to spawn hook process:`, err) - reject(err) - }) + .on('error', err => reject(err)) // hooks never write to stdout // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 From 03b9f6dd5c5fa532b9c340564858f61d3fe8e1d7 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 14:18:42 +0100 Subject: [PATCH 143/865] Update hooks-proxy.ts --- app/src/lib/hooks/hooks-proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index b1988a52097..fed26025eb9 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -145,7 +145,7 @@ export const createHooksProxy = ( .on('close', (code, signal) => resolve({ code, signal })) .on('error', err => reject(err)) - // hooks never write to stdout + // git-hook run takes care of ensuring we only get hook output on stderr // https://github.com/git/git/blob/4cf919bd7b946477798af5414a371b23fd68bf93/hook.c#L73C6-L73C22 child.stderr.pipe(conn.stderr, { end: false }).on('error', reject) child.stderr.on('data', data => terminalOutput.push(data)) From 1e5ae68691b8aec8f907f4406e8d4dd82416b512 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 14:18:51 +0100 Subject: [PATCH 144/865] Fix process abort handling on connection close Replaces abortController.abort() with direct abort function call when the connection closes, ensuring proper process termination. --- app/src/lib/hooks/hooks-proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index fed26025eb9..4fefb602f50 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -135,7 +135,7 @@ export const createHooksProxy = ( code: number | null signal: NodeJS.Signals | null }>((resolve, reject) => { - conn.on('close', () => abortController.abort()) + conn.on('close', abort) const child = spawn(gitPath, args, { cwd: proxyCwd, From c288eabcf08beca279954a37bc83717ad44b2847 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 14:19:52 +0100 Subject: [PATCH 145/865] Support Buffer output for git hook failures Updated hook failure handling to accept terminal output as string, Buffer, or array of Buffers across core, commit, hooks-proxy, popup model, and UI components. Added bufferTrimEnd utility and adjusted terminal rendering logic to properly display Buffer-based output. --- app/src/lib/git/commit.ts | 2 +- app/src/lib/git/core.ts | 2 +- app/src/lib/hooks/hooks-proxy.ts | 2 +- app/src/models/popup.ts | 2 +- app/src/ui/hook-failed/hook-failed.tsx | 2 +- app/src/ui/terminal.tsx | 32 ++++++++++++++++++++++++-- 6 files changed, 35 insertions(+), 7 deletions(-) diff --git a/app/src/lib/git/commit.ts b/app/src/lib/git/commit.ts index b2e245b5100..fe792ed364c 100644 --- a/app/src/lib/git/commit.ts +++ b/app/src/lib/git/commit.ts @@ -26,7 +26,7 @@ export async function createCommit( onHookProgress?: (progress: HookProgress) => void onHookFailure?: ( hookName: string, - terminalOutput: string + terminalOutput: ReadonlyArray ) => Promise<'abort' | 'ignore'> onTerminalOutputAvailable?: TerminalOutputCallback } diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 45fac74658f..8f9d79afbe1 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -86,7 +86,7 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { readonly onHookProgress?: (progress: HookProgress) => void readonly onHookFailure?: ( hookName: string, - terminalOutput: string + terminalOutput: ReadonlyArray ) => Promise<'abort' | 'ignore'> readonly onTerminalOutputAvailable?: TerminalOutputCallback diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 4fefb602f50..e3c5288a4c5 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -73,7 +73,7 @@ export const createHooksProxy = ( onHookProgress?: (progress: HookProgress) => void, onHookFailure?: ( hookName: string, - terminalOutput: string + terminalOutput: ReadonlyArray ) => Promise<'abort' | 'ignore'> ) => { return async (conn: ProcessProxyConnection) => { diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index 9977e6d8c32..f1671c319db 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -470,7 +470,7 @@ export type PopupDetail = | { type: PopupType.HookFailed hookName: string - terminalOutput: string + terminalOutput: string | Buffer | ReadonlyArray resolve: (value: 'abort' | 'ignore') => void } | { diff --git a/app/src/ui/hook-failed/hook-failed.tsx b/app/src/ui/hook-failed/hook-failed.tsx index f8d1f8ba1a1..294b92b6089 100644 --- a/app/src/ui/hook-failed/hook-failed.tsx +++ b/app/src/ui/hook-failed/hook-failed.tsx @@ -6,7 +6,7 @@ import { Terminal } from '../terminal' interface IHookFailedProps { readonly hookName: string - readonly terminalOutput: string + readonly terminalOutput: string | Buffer | ReadonlyArray readonly resolve: (value: 'abort' | 'ignore') => void readonly onDismissed: () => void } diff --git a/app/src/ui/terminal.tsx b/app/src/ui/terminal.tsx index e4138e0cbb8..40c0266559d 100644 --- a/app/src/ui/terminal.tsx +++ b/app/src/ui/terminal.tsx @@ -13,9 +13,25 @@ export const defaultTerminalOptions: Readonly = { screenReaderMode: true, } +const bufferTrimEnd = (value: Buffer): Buffer => { + let i + for (i = value.length - 1; i >= 0; i--) { + switch (value[i]) { + case 0x20: // space + case 0x09: // tab + case 0x0a: // LF + case 0x0d: // CR + continue + default: + break + } + } + return i === value.length ? value : value.subarray(0, i) +} + export type TerminalProps = ITerminalOptions & ITerminalInitOnlyOptions & { - readonly terminalOutput?: string + readonly terminalOutput?: string | Buffer | ReadonlyArray readonly hideCursor?: boolean } @@ -55,7 +71,19 @@ export class Terminal extends React.Component { if (hideCursor !== false) { this.terminal.write('\x1b[?25l') // hide cursor if (terminalOutput) { - this.terminal.write(terminalOutput.trimEnd()) + if (typeof terminalOutput === 'string') { + this.terminal.write(terminalOutput.trimEnd()) + } else if (Buffer.isBuffer(terminalOutput)) { + this.terminal.write(bufferTrimEnd(terminalOutput)) + } else { + for (let i = 0; i < terminalOutput.length; i++) { + this.terminal.write( + i === terminalOutput.length - 1 + ? bufferTrimEnd(terminalOutput[i]) + : terminalOutput[i] + ) + } + } } } } From a544320a10e27bf3e674d008646147d2fedfee2b Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 14:20:09 +0100 Subject: [PATCH 146/865] Remove unused repoHooks argument from createHooksProxy Eliminated the repoHooks parameter from the createHooksProxy call in withHooksEnv, as it is no longer needed. --- app/src/lib/hooks/hooks-proxy.ts | 1 - app/src/lib/hooks/with-hooks-env.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index e3c5288a4c5..36cab547fff 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -67,7 +67,6 @@ const exitWithError = ( ) => exitWithMessage(connection, message, exitCode) export const createHooksProxy = ( - repoHooks: string[], tmpDir: string, getShellEnv: () => Promise>, onHookProgress?: (progress: HookProgress) => void, diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index 8391203c039..4442c808b84 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -47,7 +47,6 @@ export async function withHooksEnv( const token = crypto.randomUUID() const tmpHooksDir = await mkdtemp(join(tmpdir(), 'desktop-git-hooks-')) const hooksProxy = createHooksProxy( - repoHooks, tmpHooksDir, memoizedGetShellEnv, options?.onHookProgress, From 081aa191c53984f2c63d0549a2a256a5930e0b5d Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 14:21:13 +0100 Subject: [PATCH 147/865] Expand terminalOutput type for hook failure handlers Updated the onHookFailure callback signature in commit, core, and hooks-proxy modules to accept terminalOutput as string, Buffer, or ReadonlyArray instead of only ReadonlyArray. This change increases flexibility for handling hook output in different formats. --- app/src/lib/git/commit.ts | 2 +- app/src/lib/git/core.ts | 2 +- app/src/lib/hooks/hooks-proxy.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/lib/git/commit.ts b/app/src/lib/git/commit.ts index fe792ed364c..c28f7e4d212 100644 --- a/app/src/lib/git/commit.ts +++ b/app/src/lib/git/commit.ts @@ -26,7 +26,7 @@ export async function createCommit( onHookProgress?: (progress: HookProgress) => void onHookFailure?: ( hookName: string, - terminalOutput: ReadonlyArray + terminalOutput: string | Buffer | ReadonlyArray ) => Promise<'abort' | 'ignore'> onTerminalOutputAvailable?: TerminalOutputCallback } diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 8f9d79afbe1..c27ab7716fb 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -86,7 +86,7 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { readonly onHookProgress?: (progress: HookProgress) => void readonly onHookFailure?: ( hookName: string, - terminalOutput: ReadonlyArray + terminalOutput: string | Buffer | ReadonlyArray ) => Promise<'abort' | 'ignore'> readonly onTerminalOutputAvailable?: TerminalOutputCallback diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 36cab547fff..5ecc00a475d 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -72,7 +72,7 @@ export const createHooksProxy = ( onHookProgress?: (progress: HookProgress) => void, onHookFailure?: ( hookName: string, - terminalOutput: ReadonlyArray + terminalOutput: string | Buffer | ReadonlyArray ) => Promise<'abort' | 'ignore'> ) => { return async (conn: ProcessProxyConnection) => { From 4340ccbcaf7292fe4fc34b5dda799014cfc3ec5b Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 14:24:28 +0100 Subject: [PATCH 148/865] Refactor TerminalOutput type usage across codebase Unified the TerminalOutput type to replace various string/Buffer/Buffer[] usages in hook, commit, and terminal-related interfaces and components. This improves type consistency and simplifies handling of terminal output data throughout the application. --- app/src/lib/git/commit.ts | 3 ++- app/src/lib/git/core.ts | 6 ++++-- app/src/lib/hooks/hooks-proxy.ts | 4 ++-- app/src/models/popup.ts | 4 ++-- app/src/ui/commit-progress/commit-progress.tsx | 4 +++- app/src/ui/hook-failed/hook-failed.tsx | 3 ++- app/src/ui/terminal.tsx | 11 ++++++++--- 7 files changed, 23 insertions(+), 12 deletions(-) diff --git a/app/src/lib/git/commit.ts b/app/src/lib/git/commit.ts index c28f7e4d212..627cc5a3ec5 100644 --- a/app/src/lib/git/commit.ts +++ b/app/src/lib/git/commit.ts @@ -2,6 +2,7 @@ import { git, HookProgress, parseCommitSHA, + TerminalOutput, TerminalOutputCallback, } from './core' import { stageFiles } from './update-index' @@ -26,7 +27,7 @@ export async function createCommit( onHookProgress?: (progress: HookProgress) => void onHookFailure?: ( hookName: string, - terminalOutput: string | Buffer | ReadonlyArray + terminalOutput: TerminalOutput ) => Promise<'abort' | 'ignore'> onTerminalOutputAvailable?: TerminalOutputCallback } diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index c27ab7716fb..15c9bcf4ab6 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -35,7 +35,9 @@ export const isMaxBufferExceededError = ( ) } -export type TerminalOutputListener = (cb: (chunk: Buffer | string) => void) => { +export type TerminalOutput = string | Buffer | Buffer[] + +export type TerminalOutputListener = (cb: (chunk: TerminalOutput) => void) => { unsubscribe: () => void } @@ -86,7 +88,7 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { readonly onHookProgress?: (progress: HookProgress) => void readonly onHookFailure?: ( hookName: string, - terminalOutput: string | Buffer | ReadonlyArray + terminalOutput: TerminalOutput ) => Promise<'abort' | 'ignore'> readonly onTerminalOutputAvailable?: TerminalOutputCallback diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 5ecc00a475d..69c9ab8bd55 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -4,7 +4,7 @@ import { createWriteStream } from 'fs' import { basename, join, resolve } from 'path' import { ProcessProxyConnection } from 'process-proxy' import { pipeline } from 'stream/promises' -import type { HookProgress } from '../git' +import type { HookProgress, TerminalOutput } from '../git' import { resolveGitBinary } from 'dugite' const hooksUsingStdin = ['post-rewrite'] @@ -72,7 +72,7 @@ export const createHooksProxy = ( onHookProgress?: (progress: HookProgress) => void, onHookFailure?: ( hookName: string, - terminalOutput: string | Buffer | ReadonlyArray + terminalOutput: TerminalOutput ) => Promise<'abort' | 'ignore'> ) => { return async (conn: ProcessProxyConnection) => { diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index f1671c319db..e36224591a5 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -25,7 +25,7 @@ import { UnreachableCommitsTab } from '../ui/history/unreachable-commits-dialog' import { IAPIComment } from '../lib/api' import { ISecretScanResult } from '../ui/secret-scanning/push-protection-error-dialog' import { BypassReasonType } from '../ui/secret-scanning/bypass-push-protection-dialog' -import { TerminalOutputListener } from '../lib/git' +import { TerminalOutput, TerminalOutputListener } from '../lib/git' export enum PopupType { RenameBranch = 'RenameBranch', @@ -470,7 +470,7 @@ export type PopupDetail = | { type: PopupType.HookFailed hookName: string - terminalOutput: string | Buffer | ReadonlyArray + terminalOutput: TerminalOutput resolve: (value: 'abort' | 'ignore') => void } | { diff --git a/app/src/ui/commit-progress/commit-progress.tsx b/app/src/ui/commit-progress/commit-progress.tsx index 19542972460..da9b846e69c 100644 --- a/app/src/ui/commit-progress/commit-progress.tsx +++ b/app/src/ui/commit-progress/commit-progress.tsx @@ -22,7 +22,9 @@ export class CommitProgress extends React.Component { public componentDidMount() { const { unsubscribe } = this.props.subscribeToCommitOutput(chunk => - this.terminalRef.current?.write(chunk) + Array.isArray(chunk) + ? chunk.forEach(c => this.terminalRef.current?.write(c)) + : this.terminalRef.current?.write(chunk) ) this.unsubscribe = unsubscribe diff --git a/app/src/ui/hook-failed/hook-failed.tsx b/app/src/ui/hook-failed/hook-failed.tsx index 294b92b6089..e97104bbafa 100644 --- a/app/src/ui/hook-failed/hook-failed.tsx +++ b/app/src/ui/hook-failed/hook-failed.tsx @@ -3,10 +3,11 @@ import * as React from 'react' import { Dialog, DialogContent, DialogFooter } from '../dialog' import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' import { Terminal } from '../terminal' +import { TerminalOutput } from '../../lib/git' interface IHookFailedProps { readonly hookName: string - readonly terminalOutput: string | Buffer | ReadonlyArray + readonly terminalOutput: TerminalOutput readonly resolve: (value: 'abort' | 'ignore') => void readonly onDismissed: () => void } diff --git a/app/src/ui/terminal.tsx b/app/src/ui/terminal.tsx index 40c0266559d..a53f0d50c4c 100644 --- a/app/src/ui/terminal.tsx +++ b/app/src/ui/terminal.tsx @@ -5,6 +5,7 @@ import { } from '@xterm/xterm' import React from 'react' import { getMonospaceFontFamily } from './get-monospace-font-family' +import { TerminalOutput } from '../lib/git' export const defaultTerminalOptions: Readonly = { convertEol: true, @@ -31,7 +32,7 @@ const bufferTrimEnd = (value: Buffer): Buffer => { export type TerminalProps = ITerminalOptions & ITerminalInitOnlyOptions & { - readonly terminalOutput?: string | Buffer | ReadonlyArray + readonly terminalOutput?: TerminalOutput readonly hideCursor?: boolean } @@ -43,8 +44,12 @@ export class Terminal extends React.Component { return this.terminal } - public write(data: string | Buffer) { - this.terminal?.write(data) + public write(data: TerminalOutput) { + if (Array.isArray(data)) { + data.forEach(chunk => this.terminal?.write(chunk)) + } else { + this.terminal?.write(data) + } } public componentWillUnmount(): void { From 35597af4356d0e0e7060be3fe397621fbc5c972a Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 10 Nov 2025 14:26:45 +0100 Subject: [PATCH 149/865] Refactor terminal output writing logic Removed custom bufferTrimEnd function and simplified terminal output handling by delegating to the write method. This improves code clarity and maintainability. --- app/src/ui/terminal.tsx | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/app/src/ui/terminal.tsx b/app/src/ui/terminal.tsx index a53f0d50c4c..7ca37fdbc71 100644 --- a/app/src/ui/terminal.tsx +++ b/app/src/ui/terminal.tsx @@ -14,22 +14,6 @@ export const defaultTerminalOptions: Readonly = { screenReaderMode: true, } -const bufferTrimEnd = (value: Buffer): Buffer => { - let i - for (i = value.length - 1; i >= 0; i--) { - switch (value[i]) { - case 0x20: // space - case 0x09: // tab - case 0x0a: // LF - case 0x0d: // CR - continue - default: - break - } - } - return i === value.length ? value : value.subarray(0, i) -} - export type TerminalProps = ITerminalOptions & ITerminalInitOnlyOptions & { readonly terminalOutput?: TerminalOutput @@ -76,19 +60,7 @@ export class Terminal extends React.Component { if (hideCursor !== false) { this.terminal.write('\x1b[?25l') // hide cursor if (terminalOutput) { - if (typeof terminalOutput === 'string') { - this.terminal.write(terminalOutput.trimEnd()) - } else if (Buffer.isBuffer(terminalOutput)) { - this.terminal.write(bufferTrimEnd(terminalOutput)) - } else { - for (let i = 0; i < terminalOutput.length; i++) { - this.terminal.write( - i === terminalOutput.length - 1 - ? bufferTrimEnd(terminalOutput[i]) - : terminalOutput[i] - ) - } - } + this.write(terminalOutput) } } } From bf86dba788a46d55e4559c290a7656253461a8a5 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Tue, 11 Nov 2025 13:28:55 +0100 Subject: [PATCH 150/865] Update commit-progress.tsx --- app/src/ui/commit-progress/commit-progress.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/ui/commit-progress/commit-progress.tsx b/app/src/ui/commit-progress/commit-progress.tsx index da9b846e69c..19542972460 100644 --- a/app/src/ui/commit-progress/commit-progress.tsx +++ b/app/src/ui/commit-progress/commit-progress.tsx @@ -22,9 +22,7 @@ export class CommitProgress extends React.Component { public componentDidMount() { const { unsubscribe } = this.props.subscribeToCommitOutput(chunk => - Array.isArray(chunk) - ? chunk.forEach(c => this.terminalRef.current?.write(c)) - : this.terminalRef.current?.write(chunk) + this.terminalRef.current?.write(chunk) ) this.unsubscribe = unsubscribe From 695449cd707043b010a69e7bc7348beb99223014 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Tue, 11 Nov 2025 19:15:33 +0100 Subject: [PATCH 151/865] Refactor Git hook interception and filtering logic Changed IGitExecutionOptions.interceptHooks to accept only string arrays. Updated getRepoHooks to yield hook names instead of paths and improved filtering logic. Refactored withHooksEnv to use the new hook name interface and simplified environment handling for intercepted hooks. --- app/src/lib/git/core.ts | 2 +- app/src/lib/hooks/get-repo-hooks.ts | 34 ++++++++++++------------ app/src/lib/hooks/with-hooks-env.ts | 41 +++++++++++------------------ 3 files changed, 33 insertions(+), 44 deletions(-) diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 15c9bcf4ab6..69dc5f7ce57 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -84,7 +84,7 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { */ readonly isBackgroundTask?: boolean - readonly interceptHooks?: boolean | string[] + readonly interceptHooks?: string[] readonly onHookProgress?: (progress: HookProgress) => void readonly onHookFailure?: ( hookName: string, diff --git a/app/src/lib/hooks/get-repo-hooks.ts b/app/src/lib/hooks/get-repo-hooks.ts index edde20cbfec..782ab389eab 100644 --- a/app/src/lib/hooks/get-repo-hooks.ts +++ b/app/src/lib/hooks/get-repo-hooks.ts @@ -1,6 +1,6 @@ import { exec } from 'dugite' import { access, constants, readdir } from 'fs/promises' -import { join, resolve } from 'path' +import { basename, join, resolve } from 'path' const isExecutable = (path: string) => access(path, constants.X_OK) @@ -38,6 +38,14 @@ const knownHooks = [ 'post-index-change', ] +/** + * Returns the names of executable Git hooks found in the given repository. + * + * @param path The file system path to the Git repository (root of working + * directory). + * @param filter An optional array of hook names to filter the results. + * Including '*' will return all hooks. + */ export async function* getRepoHooks(path: string, filter?: string[]) { const { exitCode, stdout } = await exec( ['config', '-z', '--get', 'core.hooksPath'], @@ -53,34 +61,26 @@ export async function* getRepoHooks(path: string, filter?: string[]) { .then(entries => entries.filter(x => x.isFile())) .catch(() => []) - for (const hook of files) { - const hookName = hook.name.endsWith('.exe') - ? hook.name.slice(0, -4) - : hook.name + const matchAll = filter?.includes('*') - if (filter && !filter.includes(hookName)) { - continue - } + for (const file of files) { + const hookName = basename(file.name, '.exe') - if (!knownHooks.includes(hookName)) { + if (matchAll || filter?.includes(hookName) === false) { continue } - if (hookName.endsWith('.sample')) { + if (!knownHooks.includes(hookName)) { continue } - const hookPath = join(hook.parentPath, hook.name) - if (__WIN32__) { // On Windows we have to assume that any valid hook name is executable // because the executable bit is not used there. Git looks for a shebang // but that seems expensive to check here :shrug: - yield hookPath - } else { - if (await isExecutable(hookPath)) { - yield hookPath - } + yield hookName + } else if (await isExecutable(join(file.parentPath, file.name))) { + yield hookName } } } diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index 4442c808b84..ce2c23a135a 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -1,7 +1,7 @@ import { cp, mkdtemp, rm } from 'fs/promises' import { AddressInfo } from 'net' import { tmpdir } from 'os' -import { basename, join } from 'path' +import { join } from 'path' import { createProxyProcessServer } from 'process-proxy' import { enableHooksEnvironment } from '../feature-flag' import type { IGitExecutionOptions } from '../git/core' @@ -13,23 +13,16 @@ import memoizeOne from 'memoize-one' export async function withHooksEnv( fn: (env: Record | undefined) => Promise, path: string, - options: IGitExecutionOptions | undefined + opts: IGitExecutionOptions | undefined ): Promise { - const interceptHooks = options?.interceptHooks ?? false - - if (!interceptHooks || !enableHooksEnvironment()) { - return fn(options?.env) + if (!opts?.interceptHooks || !enableHooksEnvironment()) { + return fn(opts?.env) } - const repoHooks = await Array.fromAsync( - getRepoHooks( - path, - typeof interceptHooks === 'object' ? interceptHooks : undefined - ) - ) + const hooks = await Array.fromAsync(getRepoHooks(path, opts.interceptHooks)) - if (repoHooks.length === 0) { - return fn(options?.env) + if (hooks.length === 0) { + return fn(opts?.env) } const memoizedGetShellEnv = memoizeOne(async () => { @@ -49,8 +42,8 @@ export async function withHooksEnv( const hooksProxy = createHooksProxy( tmpHooksDir, memoizedGetShellEnv, - options?.onHookProgress, - options?.onHookFailure + opts?.onHookProgress, + opts?.onHookFailure ) const server = createProxyProcessServer( @@ -61,22 +54,18 @@ export async function withHooksEnv( }), { validateConnection: async receivedToken => receivedToken === token } ) - const port = await new Promise((resolve, reject) => { - server.listen(0, '127.0.0.1', () => { + const port = await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port) - }) + ) }) try { - for (const hook of repoHooks) { - const cleanHooksName = __WIN32__ - ? basename(hook).replace(/\.exe$/i, '') - : basename(hook) - - await cp(processProxyPath, join(tmpHooksDir, `${cleanHooksName}${ext}`)) + for (const hook of hooks) { + await cp(processProxyPath, join(tmpHooksDir, `${hook}${ext}`)) } const existingGitEnvConfig = - options?.env?.['GIT_CONFIG_PARAMETERS'] ?? + opts?.env?.['GIT_CONFIG_PARAMETERS'] ?? process.env['GIT_CONFIG_PARAMETERS'] ?? '' From 8f6d365fb16087005493fb3a72ffa727fee15c29 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Tue, 11 Nov 2025 19:31:05 +0100 Subject: [PATCH 152/865] Refactor hooks-proxy for improved readability Renamed ProcessProxyConnection to Connection for brevity, simplified function signatures, and streamlined hook name extraction. Enhanced debug logging to include elapsed time and signal information, and passed terminalOutput directly to onHookFailure for consistency. --- app/src/lib/hooks/hooks-proxy.ts | 49 +++++++++++--------------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 69c9ab8bd55..3a2c76e1c62 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -2,7 +2,7 @@ import { spawn } from 'child_process' import { randomBytes } from 'crypto' import { createWriteStream } from 'fs' import { basename, join, resolve } from 'path' -import { ProcessProxyConnection } from 'process-proxy' +import { ProcessProxyConnection as Connection } from 'process-proxy' import { pipeline } from 'stream/promises' import type { HookProgress, TerminalOutput } from '../git' import { resolveGitBinary } from 'dugite' @@ -42,14 +42,10 @@ const debug = (message: string, error?: Error) => { log.debug(`hooks: ${message}`, error) } -const exitWithMessage = ( - connection: ProcessProxyConnection, - message: string, - exitCode = 0 -) => { +const exitWithMessage = (conn: Connection, msg: string, exitCode = 0) => { return new Promise(resolve => { - connection.stderr.end(`${message}\n`) - connection.exit(exitCode).then(resolve, err => { + conn.stderr.end(`${msg}\n`) + conn.exit(exitCode).then(resolve, err => { debug( `failed to exit proxy: ${ err instanceof Error ? err.message : String(err) @@ -60,11 +56,8 @@ const exitWithMessage = ( }) } -const exitWithError = ( - connection: ProcessProxyConnection, - message: string, - exitCode = 1 -) => exitWithMessage(connection, message, exitCode) +const exitWithError = (conn: Connection, msg: string, exitCode = 1) => + exitWithMessage(conn, msg, exitCode) export const createHooksProxy = ( tmpDir: string, @@ -75,15 +68,13 @@ export const createHooksProxy = ( terminalOutput: TerminalOutput ) => Promise<'abort' | 'ignore'> ) => { - return async (conn: ProcessProxyConnection) => { + return async (conn: Connection) => { const startTime = Date.now() const proxyArgs = await conn.getArgs() const proxyEnv = await conn.getEnv() const proxyCwd = await conn.getCwd() - const hookName = __WIN32__ - ? basename(proxyArgs[0]).replace(/\.exe$/i, '') - : basename(proxyArgs[0]) + const hookName = basename(proxyArgs[0], __WIN32__ ? '.exe' : undefined) const abortController = new AbortController() const abort = () => abortController.abort() @@ -106,15 +97,13 @@ export const createHooksProxy = ( } if (abortController.signal.aborted) { - debug(`hook ${hookName} aborted before execution`) - await exitWithError(conn, `Hook ${hookName} aborted`) + debug(`${hookName}: aborted before execution`) + await exitWithError(conn, `hook ${hookName} aborted`) return } const args = [ - 'hook', - 'run', - hookName, + ...['hook', 'run', hookName], // We always copy our pre-auto-gc hook in order to be able to tell the // user that the reason their commit is taking so long is because Git is // performing garbage collection, but it's unlikely that the user has a @@ -150,24 +139,20 @@ export const createHooksProxy = ( child.stderr.on('data', data => terminalOutput.push(data)) }) + const elapsedSeconds = (Date.now() - startTime) / 1000 + if (signal !== null) { - debug(`hook ${hookName} was killed by signal ${signal}`) + debug(`${hookName}: killed by signal ${signal} after ${elapsedSeconds}s`) + } else { + debug(`${hookName}: exited with code ${code} after ${elapsedSeconds}s`) } - const elapsedSeconds = (Date.now() - startTime) / 1000 - debug( - `executed ${hookName}: exited with code ${code} in ${elapsedSeconds}s` - ) - const ignoreError = code !== null && code !== 0 && !ignoredOnFailureHooks.includes(hookName) && onHookFailure - ? (await onHookFailure( - hookName, - Buffer.concat(terminalOutput).toString() - )) === 'ignore' + ? (await onHookFailure(hookName, terminalOutput)) === 'ignore' : false if (ignoreError) { From 70b76d67e49142e4c5dbd4a2590694fcdba25347 Mon Sep 17 00:00:00 2001 From: Rainymy <48211766+Rainymy@users.noreply.github.com> Date: Thu, 13 Nov 2025 14:23:43 +0100 Subject: [PATCH 153/865] Add Windows support for Zed --- app/src/lib/editors/win32.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/lib/editors/win32.ts b/app/src/lib/editors/win32.ts index 41d7e601bd6..d813f1f796b 100644 --- a/app/src/lib/editors/win32.ts +++ b/app/src/lib/editors/win32.ts @@ -496,6 +496,15 @@ const editors: WindowsExternalEditor[] = [ displayNamePrefixes: ['Windsurf', 'Windsurf (User)'], publishers: ['Codeium'], }, + { + name: 'Zed', + registryKeys: [ + CurrentUserUninstallKey('{2DB0DA96-CA55-49BB-AF4F-64AF36A86712}_is1'), + ], + installLocationRegistryKey: 'DisplayIcon', + displayNamePrefixes: ['Zed'], + publishers: ['Zed Industries'], + }, ] function getKeyOrEmpty( From 8291ec350abd260c518de7365fb42580fed46cc1 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Thu, 13 Nov 2025 14:00:15 -0800 Subject: [PATCH 154/865] Add 'View Branch on GitHub' to branch context menu Introduces a new context menu item allowing users to view the current branch on GitHub if an upstream remote is set. Implements the handler in BranchDropdown and updates the context menu item generation logic. --- .../branch-list-item-context-menu.tsx | 9 +++++++ app/src/ui/toolbar/branch-dropdown.tsx | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/app/src/ui/branches/branch-list-item-context-menu.tsx b/app/src/ui/branches/branch-list-item-context-menu.tsx index c937379adfa..281c0847276 100644 --- a/app/src/ui/branches/branch-list-item-context-menu.tsx +++ b/app/src/ui/branches/branch-list-item-context-menu.tsx @@ -5,6 +5,7 @@ interface IBranchContextMenuConfig { name: string isLocal: boolean onRenameBranch?: (branchName: string) => void + onViewBranchOnGitHub?: () => void onViewPullRequestOnGitHub?: () => void onDeleteBranch?: (branchName: string) => void } @@ -16,6 +17,7 @@ export function generateBranchContextMenuItems( name, isLocal, onRenameBranch, + onViewBranchOnGitHub, onViewPullRequestOnGitHub, onDeleteBranch, } = config @@ -34,6 +36,13 @@ export function generateBranchContextMenuItems( action: () => clipboard.writeText(name), }) + if (onViewBranchOnGitHub !== undefined) { + items.push({ + label: 'View Branch on GitHub', + action: () => onViewBranchOnGitHub(), + }) + } + if (onViewPullRequestOnGitHub !== undefined) { items.push({ label: 'View Pull Request on GitHub', diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index 7ad501d08b2..35c68b4e72d 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -309,6 +309,9 @@ export class BranchDropdown extends React.Component { name: tip.branch.name, isLocal: tip.branch.type === BranchType.Local, onRenameBranch: this.onRenameBranch, + onViewBranchOnGitHub: tip.branch.upstreamRemoteName + ? this.onViewBranchOnGithub + : undefined, onViewPullRequestOnGitHub: this.props.currentPullRequest ? this.onViewPullRequestOnGithub : undefined, @@ -338,6 +341,27 @@ export class BranchDropdown extends React.Component { }) } + private onViewBranchOnGithub = () => { + const { repository } = this.props + const { tip } = this.props.repositoryState.branchesState + + if (tip.kind !== TipState.Valid) { + return + } + + const gitHubRepository = repository.gitHubRepository + if (!gitHubRepository || gitHubRepository.htmlURL === null) { + return + } + + const branchName = tip.branch.upstreamWithoutRemote ?? tip.branch.name + const url = `${gitHubRepository.htmlURL}/tree/${encodeURIComponent( + branchName + )}` + + this.props.dispatcher.openInBrowser(url) + } + private onViewPullRequestOnGithub = () => { const pr = this.props.currentPullRequest From a56f5354d450afc3c3b98096f3518dc408b74b77 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Thu, 13 Nov 2025 14:02:54 -0800 Subject: [PATCH 155/865] Fix typo in branch dropdown handler name Renamed onViewBranchOnGithub to onViewBranchOnGitHub for consistency and to fix a typo in the branch dropdown component. --- app/src/ui/toolbar/branch-dropdown.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index 35c68b4e72d..c4582e110e5 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -310,7 +310,7 @@ export class BranchDropdown extends React.Component { isLocal: tip.branch.type === BranchType.Local, onRenameBranch: this.onRenameBranch, onViewBranchOnGitHub: tip.branch.upstreamRemoteName - ? this.onViewBranchOnGithub + ? this.onViewBranchOnGitHub : undefined, onViewPullRequestOnGitHub: this.props.currentPullRequest ? this.onViewPullRequestOnGithub @@ -341,7 +341,7 @@ export class BranchDropdown extends React.Component { }) } - private onViewBranchOnGithub = () => { + private onViewBranchOnGitHub = () => { const { repository } = this.props const { tip } = this.props.repositoryState.branchesState From 5e59ca8f1b69da4b0771629064f75f99e9b63b65 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Thu, 13 Nov 2025 14:06:16 -0800 Subject: [PATCH 156/865] Refactor branch dropdown GitHub view logic Simplified variable usage in onViewBranchOnGitHub by directly accessing props and removing redundant assignments. --- app/src/ui/toolbar/branch-dropdown.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index c4582e110e5..02bafbc5ef7 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -342,14 +342,13 @@ export class BranchDropdown extends React.Component { } private onViewBranchOnGitHub = () => { - const { repository } = this.props - const { tip } = this.props.repositoryState.branchesState + const tip = this.props.repositoryState.branchesState.tip + const gitHubRepository = this.props.repository.gitHubRepository if (tip.kind !== TipState.Valid) { return } - const gitHubRepository = repository.gitHubRepository if (!gitHubRepository || gitHubRepository.htmlURL === null) { return } From 5d1e195b4c9d11e1ef5558808d09b6feb8857ca3 Mon Sep 17 00:00:00 2001 From: Neel Bhalla Date: Sat, 15 Nov 2025 12:47:23 -0500 Subject: [PATCH 157/865] fix: address comments --- app/src/lib/stores/app-store.ts | 2 +- app/src/lib/stores/git-store.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index fcff810ea35..881893d7525 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -1745,7 +1745,7 @@ export class AppStore extends TypedBaseStore { } this.repositoryStateCache.updateCompareState(repository, () => ({ - commitSHAs: commits.concat(newCommits ?? []), + commitSHAs: commits.concat(newCommits), })) this.emitUpdate() } diff --git a/app/src/lib/stores/git-store.ts b/app/src/lib/stores/git-store.ts index 4c669e55baf..95aad627b62 100644 --- a/app/src/lib/stores/git-store.ts +++ b/app/src/lib/stores/git-store.ts @@ -603,6 +603,8 @@ export class GitStore extends BaseStore { * If the tip of the repository does not have commits (i.e. is unborn), this * should be invoked with `null`, which clears any existing commits from the * store. + * + * @returns The list of commit SHAs that were ammended to the list of commits, or null if not applicable */ public async loadLocalCommits( branch: Branch | null, From 4c735e53b7e904952c9e38e8dc5370407b036e4d Mon Sep 17 00:00:00 2001 From: Arjun Suresh Date: Thu, 20 Nov 2025 13:41:58 +0100 Subject: [PATCH 158/865] check-runs: set max-height to enabling scroll of ci-check-run-list --- app/styles/ui/check-runs/_ci-check-run-list.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/styles/ui/check-runs/_ci-check-run-list.scss b/app/styles/ui/check-runs/_ci-check-run-list.scss index 9fdbcd03dab..17420c34f7b 100644 --- a/app/styles/ui/check-runs/_ci-check-run-list.scss +++ b/app/styles/ui/check-runs/_ci-check-run-list.scss @@ -1,4 +1,7 @@ .ci-check-run-list { + + max-height: 70vh; + :first-child { &.ci-check-run-list-group { :first-child { From d59ebae08466177a3028b14ad8f03a0942e9499b Mon Sep 17 00:00:00 2001 From: Arjun Suresh <123arjunsuresh@gmail.com> Date: Thu, 20 Nov 2025 13:57:26 +0100 Subject: [PATCH 159/865] fix lint --- app/styles/ui/check-runs/_ci-check-run-list.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/app/styles/ui/check-runs/_ci-check-run-list.scss b/app/styles/ui/check-runs/_ci-check-run-list.scss index 17420c34f7b..9891e471161 100644 --- a/app/styles/ui/check-runs/_ci-check-run-list.scss +++ b/app/styles/ui/check-runs/_ci-check-run-list.scss @@ -1,5 +1,4 @@ .ci-check-run-list { - max-height: 70vh; :first-child { From 413ff50e11f984ef36a0f0bf40b8cbed9f1c5ec8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:00:26 +0000 Subject: [PATCH 160/865] Initial plan From c8c673a20729ed5ef6da9bbae0aa1a8e30b4463f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:02:39 +0000 Subject: [PATCH 161/865] Add permissions to lint job in CI workflow Co-authored-by: niik <634063+niik@users.noreply.github.com> --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e759efb8a36..feb45c52b1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,8 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + permissions: + contents: read env: RELEASE_CHANNEL: ${{ inputs.environment }} steps: From 61743784cde097a58f7f9289f0ec6d81b541771e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:14:45 +0000 Subject: [PATCH 162/865] Initial plan From 345634a2c8fc80ceaabfa87c33341628243249e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:20:25 +0000 Subject: [PATCH 163/865] Add Copilot instructions for repository Co-authored-by: niik <634063+niik@users.noreply.github.com> --- .github/copilot-instructions.md | 224 ++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..6b7287ba6c8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,224 @@ +# GitHub Desktop - Copilot Instructions + +This repository contains GitHub Desktop, an open-source Electron-based GitHub application written in TypeScript and React. + +## Technology Stack + +- **Language**: TypeScript (strict mode enabled) +- **UI Framework**: React 16.x +- **Runtime**: Electron 38.x +- **Build Tool**: Webpack with parallel builds +- **Package Manager**: Yarn (>= 1.9) +- **Node Version**: >= 10 (see `.node-version` for specific version) +- **Testing**: Node.js built-in test runner + +## Code Style & Conventions + +### TypeScript Style + +- **Use strict TypeScript** with all strict mode checks enabled +- **Naming conventions**: + - PascalCase for classes + - camelCase for methods and properties + - Interfaces MUST start with `I` prefix (e.g., `IRepository`, `ICommit`) + - Avoid reserved keywords as variable names (`any`, `Number`, `String`, `Boolean`, `Undefined`, etc.) +- **Type safety**: + - Prefer `as` for type assertions + - Use the `assertNever` helper for exhaustiveness checks in switch statements + - Write custom type definitions when none exist + - Avoid `any` unless absolutely necessary +- **Member ordering in classes**: + 1. Static fields + 2. Static methods + 3. Instance fields + 4. Abstract methods + 5. Constructor + 6. Instance methods +- **Visibility modifiers**: Always use explicit member accessibility (`public`, `private`, `protected`) +- **Avoid default exports**: Use named exports only + +### React Conventions + +- **Props and State**: Always use `readonly` for props and state types to prevent accidental mutation +- **JSX**: Always use explicit boolean values (e.g., `` instead of ``) +- **No binding in JSX**: Use arrow functions or pre-bind methods instead of binding in render +- **No string refs**: Use React refs API instead +- **Accessibility**: Autofocus is allowed when used appropriately in dialogs and focused contexts + +### Immutability & Pure Functions + +- **Prefer `const` over `let`**: Use `const` whenever possible to enforce immutability +- **Prefer ternary over reassignment**: Use `const a = condition ? value : otherValue` instead of `let` with conditional reassignment +- **Pure functions**: Write functions that operate only on their parameters when possible +- **Lift computation logic**: Separate data gathering from data processing into different functions +- **Use readonly arrays**: Mark arrays and objects as `readonly` in interfaces and function parameters + +### Import Restrictions + +- **Never import `ipcRenderer` directly** from `electron` or `electron/renderer` - use `import * as ipcRenderer from 'ipc-renderer'` for strongly typed IPC methods +- **Never import `ipcMain` directly** from `electron` or `electron/main` - use `import * as ipcMain from 'ipc-main'` for strongly typed IPC methods + +### Code Quality + +- **Curly braces**: Always use curly braces for control structures +- **Strict equality**: Use `===` and `!==` (smart equality checking allowed) +- **No `eval`**: Never use `eval()` +- **No `var`**: Use `const` or `let` +- **Async operations**: Use async/await, avoid synchronous Node.js APIs in application code (use `Sync` suffix when necessary) +- **For scripts**: Synchronous APIs are preferred for readability + +### Documentation + +- **Use JSDoc format** for documentation with `/**` opener (exactly two stars) +- **Document public APIs**: All public classes, methods, and properties should have JSDoc comments +- **Format**: Use a short title line followed by blank line before detailed description +- **AppStore methods**: Internal methods called by Dispatcher should be prefixed with `_` and include comment: `/** This shouldn't be called directly. See 'Dispatcher'. */` + +### ESLint Rules + +The codebase uses comprehensive ESLint rules. Key custom rules: +- `insecure-random`: Prevents use of insecure random number generation +- `react-no-unbound-dispatcher-props`: Enforces proper dispatcher prop handling +- `react-readonly-props-and-state`: Prevents mutation of React props and state +- `react-proper-lifecycle-methods`: Enforces correct React lifecycle usage +- `no-loosely-typed-webcontents-ipc`: Ensures type-safe IPC communication + +## Building & Testing + +### Development Workflow + +```bash +# Install dependencies +yarn + +# Development build +yarn build:dev + +# Start the application (changes compile in background, reload with Ctrl/Cmd+Alt+R) +yarn start + +# Production build +yarn build:prod + +# Clean rebuild +yarn clean-slate && yarn build:dev +``` + +### Testing + +```bash +# Run all unit tests +yarn test + +# Run specific test file +yarn test + +# Run tests in directory +yarn test + +# Run tests matching pattern +yarn test --test-name-pattern + +# Run script tests +yarn test:script + +# Run ESLint tests +yarn test:eslint +``` + +**Test Conventions**: +- Use Node.js built-in test runner (not Jest or Mocha) +- Test files should be in `app/test/unit/` directory +- Use `.ts` or `.tsx` extensions +- Synchronous methods are acceptable in tests for readability + +### Linting + +```bash +# Run all linters +yarn lint + +# Fix auto-fixable issues +yarn lint:fix + +# Lint source code +yarn lint:src + +# Check Markdown files +yarn markdownlint + +# Format with Prettier +yarn prettier + +# Fix Prettier issues +yarn prettier --write +``` + +## Security & Quality + +### Security + +- **Never commit secrets, passwords, or sensitive data** +- **Validate and sanitize user input** +- **Use secure cookie settings**: Set `httpOnly`, `secure`, and `sameSite: strict` for cookies +- **Follow secure coding practices**: Review code for XSS, injection, and other vulnerabilities +- **Report security issues**: Use private vulnerability reporting, not public issues + +### Git Practices + +- **No force push**: Repository does not support `git reset` or `git rebase` with force push +- **Follow commit message conventions**: Clear, descriptive commit messages +- **Reference issues**: Include issue numbers in commits when applicable + +## Project Structure + +- **`app/`**: Application source code and assets + - `app/src/`: TypeScript source files + - `app/test/`: Test files + - `app/static/`: Static assets + - `app/styles/`: SASS stylesheets +- **`script/`**: Build and utility scripts +- **`docs/`**: Documentation + - `docs/contributing/`: Contributor guides + - `docs/process/`: Process documentation + - `docs/technical/`: Technical documentation +- **`eslint-rules/`**: Custom ESLint rules +- **`.github/`**: GitHub-specific files (workflows, issue templates, contributing guide) + +## Development Tips + +- **Use the Dispatcher**: Route state-changing interactions through the `Dispatcher` to the `AppStore` +- **Avoid direct AppStore manipulation**: Methods in AppStore should be called via Dispatcher +- **Leverage TypeScript**: Use type system for compile-time verification of exhaustiveness and correctness +- **React Dev Tools**: Automatically available in development mode +- **Debugging**: Use Chrome Dev Tools (View → Toggle Developer Tools) +- **Hot reload**: Press Ctrl/Cmd+Alt+R to reload the app after changes + +## Contributing + +- See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for detailed contribution guidelines +- Follow the [Engineering Values](docs/contributing/engineering-values.md) +- Check [help wanted](https://github.com/desktop/desktop/issues?q=is%3Aissue+is%3Aopen+label%3A%22help%20wanted%22) label for good first issues +- Review [Style Guide](docs/contributing/styleguide.md) before submitting code +- Setup instructions: [docs/contributing/setup.md](docs/contributing/setup.md) + +## Code of Conduct + +This project adheres to the Contributor Covenant Code of Conduct. All interactions must be respectful and professional. + +## Resources + +- [Official website](https://desktop.github.com) +- [Getting started docs](https://docs.github.com/en/desktop/overview/getting-started-with-github-desktop) +- [Release notes](https://desktop.github.com/release-notes/) +- [Known issues](docs/known-issues.md) + +## When Making Changes + +1. **Keep changes minimal**: Make the smallest possible changes to achieve the goal +2. **Run tests frequently**: Test after each meaningful change +3. **Lint before committing**: Ensure code passes all linting checks +4. **Update documentation**: Update docs if changes affect documented behavior +5. **Follow existing patterns**: Match the style and patterns already in the codebase +6. **Don't remove working code**: Only modify what's necessary for the task +7. **Verify in the running app**: Build and run the application to manually verify changes work as expected From 647c5b940d7061cb8c72e248a16ef58e69e041ba Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 24 Nov 2025 11:51:52 +0100 Subject: [PATCH 164/865] Use ubuntu-slim for automation workflows --- .github/workflows/feature-request-comment.yml | 2 +- .github/workflows/on-issue-close.yml | 2 +- .github/workflows/pr-is-external.yml | 2 +- .github/workflows/release-pr.yml | 2 +- .github/workflows/remove-triage-label.yml | 2 +- .github/workflows/stale-issues.yml | 2 +- .github/workflows/triage-issues.yml | 4 ++-- .github/workflows/unable-to-reproduce-comment.yml | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/feature-request-comment.yml b/.github/workflows/feature-request-comment.yml index 9c65d4cc9c8..45a3f69deed 100644 --- a/.github/workflows/feature-request-comment.yml +++ b/.github/workflows/feature-request-comment.yml @@ -10,7 +10,7 @@ permissions: jobs: add-comment-to-feature-request-issues: if: github.event.label.name == 'feature-request' - runs-on: ubuntu-latest + runs-on: ubuntu-slim env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/on-issue-close.yml b/.github/workflows/on-issue-close.yml index e768226d088..ab0f1c01e64 100644 --- a/.github/workflows/on-issue-close.yml +++ b/.github/workflows/on-issue-close.yml @@ -5,7 +5,7 @@ on: - closed jobs: label_issues: - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: issues: write steps: diff --git a/.github/workflows/pr-is-external.yml b/.github/workflows/pr-is-external.yml index 55355c65cba..1e4de067644 100644 --- a/.github/workflows/pr-is-external.yml +++ b/.github/workflows/pr-is-external.yml @@ -9,7 +9,7 @@ jobs: label_issues: # pull_request.head.label = {owner}:{branch} if: startsWith(github.event.pull_request.head.label, 'desktop:') == false - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: pull-requests: write repository-projects: read diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index f03f1f074f3..5f1cba58558 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -5,7 +5,7 @@ on: create jobs: build: name: Create Release Pull Request - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: pull-requests: write steps: diff --git a/.github/workflows/remove-triage-label.yml b/.github/workflows/remove-triage-label.yml index b4834e051d4..eec13793963 100644 --- a/.github/workflows/remove-triage-label.yml +++ b/.github/workflows/remove-triage-label.yml @@ -12,7 +12,7 @@ jobs: if: github.event.label.name != 'triage' && github.event.label.name != 'more-info-needed' - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - run: gh issue edit "$NUMBER" --remove-label "$LABELS" env: diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml index 9c8ebbd91a5..838ad6f68e8 100644 --- a/.github/workflows/stale-issues.yml +++ b/.github/workflows/stale-issues.yml @@ -8,7 +8,7 @@ permissions: jobs: stale: - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - uses: actions/stale@v9 with: diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index f73bb297a68..60594cb4d31 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -11,7 +11,7 @@ permissions: jobs: label_incoming_issues: - runs-on: ubuntu-latest + runs-on: ubuntu-slim if: github.event.action == 'opened' || github.event.action == 'reopened' steps: - run: gh issue edit "$NUMBER" --add-label "$LABELS" @@ -24,7 +24,7 @@ jobs: if: github.event.action == 'unlabeled' && github.event.label.name == 'more-info-needed' - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - run: gh issue edit "$NUMBER" --add-label "$LABELS" env: diff --git a/.github/workflows/unable-to-reproduce-comment.yml b/.github/workflows/unable-to-reproduce-comment.yml index 9c13e43ee4c..9f0fb605056 100644 --- a/.github/workflows/unable-to-reproduce-comment.yml +++ b/.github/workflows/unable-to-reproduce-comment.yml @@ -10,7 +10,7 @@ permissions: jobs: add-comment-to-unable-to-reproduce-issues: if: github.event.label.name == 'unable-to-reproduce' - runs-on: ubuntu-latest + runs-on: ubuntu-slim env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} From af1d980fd6463b949f69a5bf7e25d2bb4d52d713 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 24 Nov 2025 13:16:14 +0100 Subject: [PATCH 165/865] Update coding and contribution guidelines Revised runtime, package manager, and Node.js version requirements. Clarified TypeScript style, import restrictions, and testing practices. Removed outdated instructions and added context for code style and conventions. --- .github/copilot-instructions.md | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6b7287ba6c8..06c0d5f180d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,16 +6,19 @@ This repository contains GitHub Desktop, an open-source Electron-based GitHub ap - **Language**: TypeScript (strict mode enabled) - **UI Framework**: React 16.x -- **Runtime**: Electron 38.x +- **Runtime**: Electron > 38.x (see `.npmrc` for specific version) - **Build Tool**: Webpack with parallel builds -- **Package Manager**: Yarn (>= 1.9) -- **Node Version**: >= 10 (see `.node-version` for specific version) -- **Testing**: Node.js built-in test runner +- **Package Manager**: Yarn (>= 1.21.1) +- **Node Version**: >= 22 (see `.nvmrc` for specific version) +- **Testing**: Node.js built-in test runner (run using `yarn test`, optionally providing one or more test files e.g `yarn test app/test/unit/repository-list-test.ts`) ## Code Style & Conventions +GitHub Desktop has been developed for many years through many iterations of technologies and coding styles, there may be conflicting styles in different parts of the codebase. When contributing new code or refactoring existing code, please follow the conventions outlined below. + ### TypeScript Style +- Avoid creating new classes unless necessary; prefer functions and interfaces/types, sticking to more idiomatic TypeScript/JavaScript patterns. - **Use strict TypeScript** with all strict mode checks enabled - **Naming conventions**: - PascalCase for classes @@ -23,8 +26,9 @@ This repository contains GitHub Desktop, an open-source Electron-based GitHub ap - Interfaces MUST start with `I` prefix (e.g., `IRepository`, `ICommit`) - Avoid reserved keywords as variable names (`any`, `Number`, `String`, `Boolean`, `Undefined`, etc.) - **Type safety**: - - Prefer `as` for type assertions - - Use the `assertNever` helper for exhaustiveness checks in switch statements + - Avoid using `as` for type assertions, prefer proper type narrowing and guards. + - Use the `assertNever` helper (from `app/src/lib/fatal-error.ts`) for exhaustiveness checks in switch statements or conditional logic + - Avoid non-null assertions (`!`) unless absolutely necessary - Write custom type definitions when none exist - Avoid `any` unless absolutely necessary - **Member ordering in classes**: @@ -55,8 +59,8 @@ This repository contains GitHub Desktop, an open-source Electron-based GitHub ap ### Import Restrictions -- **Never import `ipcRenderer` directly** from `electron` or `electron/renderer` - use `import * as ipcRenderer from 'ipc-renderer'` for strongly typed IPC methods -- **Never import `ipcMain` directly** from `electron` or `electron/main` - use `import * as ipcMain from 'ipc-main'` for strongly typed IPC methods +- **Never import `ipcRenderer` directly** from `electron` or `electron/renderer` - use `import * as ipcRenderer from 'ipc-renderer'` (app/src/lib/ipc-renderer.ts) for strongly typed IPC methods +- **Never import `ipcMain` directly** from `electron` or `electron/main` - use `import * as ipcMain from 'ipc-main'` (app/src/lib/ipc-main.ts) for strongly typed IPC methods ### Code Quality @@ -65,7 +69,6 @@ This repository contains GitHub Desktop, an open-source Electron-based GitHub ap - **No `eval`**: Never use `eval()` - **No `var`**: Use `const` or `let` - **Async operations**: Use async/await, avoid synchronous Node.js APIs in application code (use `Sync` suffix when necessary) -- **For scripts**: Synchronous APIs are preferred for readability ### Documentation @@ -116,9 +119,6 @@ yarn test # Run tests in directory yarn test -# Run tests matching pattern -yarn test --test-name-pattern - # Run script tests yarn test:script @@ -130,7 +130,7 @@ yarn test:eslint - Use Node.js built-in test runner (not Jest or Mocha) - Test files should be in `app/test/unit/` directory - Use `.ts` or `.tsx` extensions -- Synchronous methods are acceptable in tests for readability +- Avoid synchronous tests; use async/await. ### Linting @@ -160,13 +160,11 @@ yarn prettier --write - **Never commit secrets, passwords, or sensitive data** - **Validate and sanitize user input** -- **Use secure cookie settings**: Set `httpOnly`, `secure`, and `sameSite: strict` for cookies - **Follow secure coding practices**: Review code for XSS, injection, and other vulnerabilities - **Report security issues**: Use private vulnerability reporting, not public issues ### Git Practices -- **No force push**: Repository does not support `git reset` or `git rebase` with force push - **Follow commit message conventions**: Clear, descriptive commit messages - **Reference issues**: Include issue numbers in commits when applicable @@ -221,4 +219,3 @@ This project adheres to the Contributor Covenant Code of Conduct. All interactio 4. **Update documentation**: Update docs if changes affect documented behavior 5. **Follow existing patterns**: Match the style and patterns already in the codebase 6. **Don't remove working code**: Only modify what's necessary for the task -7. **Verify in the running app**: Build and run the application to manually verify changes work as expected From 387eb3af1bd0b188ba5db395d24c5db29942d927 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Mon, 24 Nov 2025 13:17:20 +0100 Subject: [PATCH 166/865] Update TypeScript style guide to discourage enums Added a guideline to prefer union types of string literals over enums in the TypeScript style section for more idiomatic code. --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 06c0d5f180d..aa14592b9d5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,6 +19,7 @@ GitHub Desktop has been developed for many years through many iterations of tech ### TypeScript Style - Avoid creating new classes unless necessary; prefer functions and interfaces/types, sticking to more idiomatic TypeScript/JavaScript patterns. +- Avoid using enums; prefer union types of string literals instead. - **Use strict TypeScript** with all strict mode checks enabled - **Naming conventions**: - PascalCase for classes From 8c83eec57d0960251d7bec4072b3634a57860a86 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Tue, 25 Nov 2025 10:44:43 +0100 Subject: [PATCH 167/865] Update type for onShowCommitProgress prop Refines the type of the onShowCommitProgress prop to explicitly allow undefined, improving type clarity in IFilterChangesListProps. --- app/src/ui/changes/filter-changes-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/ui/changes/filter-changes-list.tsx b/app/src/ui/changes/filter-changes-list.tsx index c56e9981cea..108654457f7 100644 --- a/app/src/ui/changes/filter-changes-list.tsx +++ b/app/src/ui/changes/filter-changes-list.tsx @@ -159,7 +159,7 @@ interface IFilterChangesListProps { readonly availableWidth: number readonly isCommitting: boolean readonly hookProgress: HookProgress | null - readonly onShowCommitProgress?: () => void + readonly onShowCommitProgress?: (() => void) | undefined readonly isGeneratingCommitMessage: boolean readonly shouldShowGenerateCommitMessageCallOut: boolean readonly commitToAmend: Commit | null From 828ee6cbd17fe0effd2ee315814ecdd29d19d699 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Tue, 25 Nov 2025 13:12:02 +0100 Subject: [PATCH 168/865] Initial wire-up of preferences --- app/src/ui/preferences/git.tsx | 192 +++++++++++++++++++--- app/src/ui/preferences/preferences.tsx | 8 + app/styles/_ui.scss | 1 + app/styles/ui/_call-to-action-bubble.scss | 11 ++ app/styles/ui/_preferences.scss | 22 ++- 5 files changed, 213 insertions(+), 21 deletions(-) create mode 100644 app/styles/ui/_call-to-action-bubble.scss diff --git a/app/src/ui/preferences/git.tsx b/app/src/ui/preferences/git.tsx index e51b053ef3f..163734b67f7 100644 --- a/app/src/ui/preferences/git.tsx +++ b/app/src/ui/preferences/git.tsx @@ -5,6 +5,10 @@ import { Ref } from '../lib/ref' import { LinkButton } from '../lib/link-button' import { Account } from '../../models/account' import { GitConfigUserForm } from '../lib/git-config-user-form' +import { TabBar } from '../tab-bar' +import { ISegmentedItem } from '../lib/vertical-segmented-control' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { Select } from '../lib/select' interface IGitProps { readonly name: string @@ -19,28 +23,172 @@ interface IGitProps { readonly onDefaultBranchChanged: (defaultBranch: string) => void readonly onEditGlobalGitConfig: () => void + + readonly selectedTabIndex?: number + readonly onSelectedTabIndexChanged: (index: number) => void +} + +interface IGitState { + readonly selectedTabIndex: number + readonly enableGitHookEnv: boolean + readonly cacheGitHookEnv: boolean + readonly selectedShell: string } -export class Git extends React.Component { +const windowsShells: ReadonlyArray> = [ + { + key: 'g4w-bash', + title: 'Git Bash (Git for Windows)', + }, + { + key: 'pwsh', + title: 'PowerShell Core', + }, + { + key: 'powershell', + title: 'PowerShell', + }, + { + key: 'cmd', + title: 'Command Prompt', + }, +] + +export class Git extends React.Component { + public constructor(props: IGitProps) { + super(props) + + this.state = { + selectedTabIndex: this.props.selectedTabIndex ?? 0, + enableGitHookEnv: false, // TODO: load from props + cacheGitHookEnv: false, // TODO: load from props + selectedShell: windowsShells[0].key, // TODO: load from props + } + } + + private onTabClicked = (index: number) => { + this.setState({ selectedTabIndex: index }) + this.props.onSelectedTabIndexChanged?.(index) + } + + private onEnableGitHookEnvChanged = ( + event: React.FormEvent + ) => { + this.setState({ enableGitHookEnv: event.currentTarget.checked }) + } + + private onCacheGitHookEnvChanged = ( + event: React.FormEvent + ) => { + this.setState({ cacheGitHookEnv: event.currentTarget.checked }) + } + + private onSelectedShellChanged = ( + event: React.FormEvent + ) => { + this.setState({ selectedShell: event.currentTarget.value }) + } + + private renderHooksSettings() { + return ( + <> + +

+ When enabled, GitHub Desktop will attempt to load environment + variables from your shell when executing Git hooks. This is useful if + your Git hooks depend on environment variables set in your shell + configuration files, a common practive for version managers such as + nvm, rbenv, asdf, etc. +

+ + {this.state.enableGitHookEnv && __WIN32__ && ( + <> + + + )} + + {this.state.enableGitHookEnv && ( + <> + + +
+ Cache hook environment variables to improve performance. Disable + if your hooks rely on frequently changing environment variables. +
+ + )} + + ) + } + public render() { return ( - - {this.renderGitConfigAuthorInfo()} - {this.renderDefaultBranchSetting()} + + + Author + Default branch + Hooks + +
{this.renderCurrentTab()}
) } + private renderCurrentTab() { + if (this.state.selectedTabIndex === 0) { + return this.renderGitConfigAuthorInfo() + } else if (this.state.selectedTabIndex === 1) { + return this.renderDefaultBranchSetting() + } else if (this.state.selectedTabIndex === 2) { + return this.renderHooksSettings() + } + + return null + } + private renderGitConfigAuthorInfo() { return ( - + <> + + {this.renderEditGlobalGitConfigInfo()} + ) } @@ -65,14 +213,20 @@ export class Git extends React.Component { still require the historical default branch name of master.

-

- These preferences will{' '} - - edit your global Git config file - - . -

+ {this.renderEditGlobalGitConfigInfo()}
) } + + private renderEditGlobalGitConfigInfo() { + return ( +

+ These preferences will{' '} + + edit your global Git config file + + . +

+ ) + } } diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index 5fadb15e0d8..c467aefbb0f 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -134,6 +134,8 @@ interface IPreferencesState { readonly underlineLinks: boolean readonly showDiffCheckMarks: boolean + + readonly selectedGitTabIndex?: number } /** @@ -387,6 +389,10 @@ export class Preferences extends React.Component< } } + private onSelectedGitTabIndexChanged = (index: number) => { + this.setState({ selectedGitTabIndex: index }) + } + private renderActiveTab() { const index = this.state.selectedIndex let View @@ -448,6 +454,8 @@ export class Preferences extends React.Component< onDefaultBranchChanged={this.onDefaultBranchChanged} isLoadingGitConfig={this.state.isLoadingGitConfig} onEditGlobalGitConfig={this.props.onEditGlobalGitConfig} + selectedTabIndex={this.state.selectedGitTabIndex} + onSelectedTabIndexChanged={this.onSelectedGitTabIndexChanged} /> ) diff --git a/app/styles/_ui.scss b/app/styles/_ui.scss index 5da446f7503..bc19e0569be 100644 --- a/app/styles/_ui.scss +++ b/app/styles/_ui.scss @@ -110,3 +110,4 @@ @import 'ui/_input-description'; @import 'ui/repository-rules/_repo-rules-failure-list'; @import 'ui/account-picker'; +@import 'ui/call-to-action-bubble'; diff --git a/app/styles/ui/_call-to-action-bubble.scss b/app/styles/ui/_call-to-action-bubble.scss new file mode 100644 index 00000000000..dfdd57b1cd2 --- /dev/null +++ b/app/styles/ui/_call-to-action-bubble.scss @@ -0,0 +1,11 @@ +.call-to-action-bubble { + font-weight: var(--font-weight-semibold); + display: inline-block; + font-size: var(--font-size-xs); + border: 1px solid var(--call-to-action-bubble-border-color); + color: var(--call-to-action-bubble-color); + padding: 1px 5px; + border-radius: var(--border-radius); + margin-left: var(--spacing-third); + cursor: pointer; +} diff --git a/app/styles/ui/_preferences.scss b/app/styles/ui/_preferences.scss index 244442c45b7..59b7aefdd10 100644 --- a/app/styles/ui/_preferences.scss +++ b/app/styles/ui/_preferences.scss @@ -1,4 +1,24 @@ #preferences { + .dialog-content.git-preferences { + padding: 0; + + .git-preferences-content { + padding: var(--spacing-double); + } + + .git-hook-shell-select { + margin-bottom: var(--spacing); + } + + .git-hooks-support-description, + .git-hooks-env-description, + .git-hooks-cache-description { + margin-top: var(--spacing); + font-size: var(--font-size-sm); + color: var(--text-secondary-color); + } + } + .preferences-container { display: flex; .tab-container { @@ -109,8 +129,6 @@ } .default-branch-component { - margin-top: var(--spacing-double); - .ref-name-text-box { margin-top: var(--spacing); } From edd75fe58978dc4c8a9d1b582dd6b2322c0135bb Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Tue, 25 Nov 2025 13:16:51 +0100 Subject: [PATCH 169/865] Apparently this is what it's called? https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_editions?view=powershell-7.5 --- app/src/ui/preferences/git.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/ui/preferences/git.tsx b/app/src/ui/preferences/git.tsx index 163734b67f7..4295a8ec3ef 100644 --- a/app/src/ui/preferences/git.tsx +++ b/app/src/ui/preferences/git.tsx @@ -46,7 +46,7 @@ const windowsShells: ReadonlyArray> = [ }, { key: 'powershell', - title: 'PowerShell', + title: 'PowerShell Desktop', }, { key: 'cmd', From 75b271362a5802ee80b7e06178f260861a874761 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Tue, 25 Nov 2025 16:17:29 +0100 Subject: [PATCH 170/865] Add and integrate Git hooks environment settings Introduces configuration utilities for Git hooks environment in a new config module, adds shell selection and caching options, and wires these settings into the preferences UI. Refactors related hooks logic to use the new config, and updates state management for preferences. Also adds a memoized setState utility for React components. --- app/src/lib/hooks/config.ts | 29 +++++++++ app/src/lib/hooks/get-shell-env.ts | 15 +++-- app/src/lib/hooks/get-shell.ts | 86 ++++++++++++++++++-------- app/src/lib/hooks/shell-escape.ts | 56 ++++++++++++----- app/src/lib/hooks/with-hooks-env.ts | 4 +- app/src/lib/set-state.ts | 36 +++++++++++ app/src/ui/preferences/git.tsx | 45 ++++++-------- app/src/ui/preferences/preferences.tsx | 52 ++++++++++++++++ 8 files changed, 251 insertions(+), 72 deletions(-) create mode 100644 app/src/lib/hooks/config.ts create mode 100644 app/src/lib/set-state.ts diff --git a/app/src/lib/hooks/config.ts b/app/src/lib/hooks/config.ts new file mode 100644 index 00000000000..8a602fd77c0 --- /dev/null +++ b/app/src/lib/hooks/config.ts @@ -0,0 +1,29 @@ +import { enableHooksEnvironment } from '../feature-flag' +import { getBoolean, setBoolean } from '../local-storage' + +export const defaultHooksEnvEnabledValue = false + +/** + * Whether the hooks environment is enabled, takes into account the + * `enableHooksEnvironment` feature flag. + */ +export const getHooksEnvEnabled = () => + enableHooksEnvironment() && + getBoolean('git-hooks-env-enabled', defaultHooksEnvEnabledValue) + +export const setHooksEnvEnabled = (enabled: boolean): void => + setBoolean('git-hooks-env-enabled', enabled) + +export const defaultCacheHooksEnvValue = true +export const getCacheHooksEnv = () => + getBoolean('git-cache-hooks-env', defaultCacheHooksEnvValue) +export const setCacheHooksEnv = (enabled: boolean): void => + setBoolean('git-cache-hooks-env', enabled) + +export const defaultGitHookEnvShell: SupportedHooksEnvShell = 'cmd' +export const getGitHookEnvShell = () => + localStorage.getItem('git-hook-env-shell') ?? defaultGitHookEnvShell +export const setGitHookEnvShell = (shell: string) => + localStorage.setItem('git-hook-env-shell', shell) + +export type SupportedHooksEnvShell = 'g4w-bash' | 'pwsh' | 'powershell' | 'cmd' diff --git a/app/src/lib/hooks/get-shell-env.ts b/app/src/lib/hooks/get-shell-env.ts index fedade6d3ef..69e3987c42b 100644 --- a/app/src/lib/hooks/get-shell-env.ts +++ b/app/src/lib/hooks/get-shell-env.ts @@ -1,15 +1,22 @@ import { join } from 'path' import { getShell } from './get-shell' import { spawn } from 'child_process' +import { SupportedHooksEnvShell } from './config' -export const getShellEnv = async (): Promise< - Record -> => { +export const getShellEnv = async ( + shellKind?: SupportedHooksEnvShell +): Promise> => { const ext = __WIN32__ ? '.exe' : '' const printenvzPath = join(__dirname, `printenvz${ext}`) + const shellInfo = await getShell(shellKind) + + if (!shellInfo) { + throw new Error('Could not determine shell to use for loading environment') + } + const { shell, args, quoteCommand, windowsVerbatimArguments, argv0 } = - await getShell() + shellInfo return await new Promise((resolve, reject) => { const child = spawn(shell, [...args, quoteCommand(printenvzPath)], { diff --git a/app/src/lib/hooks/get-shell.ts b/app/src/lib/hooks/get-shell.ts index ad3b95f9a8d..1a36fde3102 100644 --- a/app/src/lib/hooks/get-shell.ts +++ b/app/src/lib/hooks/get-shell.ts @@ -1,7 +1,9 @@ import { pathExists } from 'fs-extra' import { join } from 'path' import which from 'which' -import { bash, cmd } from './shell-escape' +import { bash, cmd, powershell } from './shell-escape' +import { SupportedHooksEnvShell } from './config' +import { assertNever } from '../fatal-error' type Shell = { shell: string @@ -31,33 +33,36 @@ const quoteArgMsys2 = (arg: string) => { return /[\s\\"'{?*~]/.test(arg) ? `"${arg.replace(/(["\\])/g, '\\$1')}"` : arg } -const findWindowsShell = async (): Promise => { +const findGitBashShell = async (): Promise => { const gitBashPath = await findGitBash() - if (gitBashPath) { - const { args, quoteCommand } = bash - return { - shell: gitBashPath, - args, - quoteCommand: (cmd, ...args) => quoteArgMsys2(quoteCommand(cmd, ...args)), - // MSYS2 doesn't use the argv it's given, instead it re-parses the - // commandline from GetCommandLineW and it doesn't comform to the - // usual Windows quoting rules. So we need to opt out of Node.js's - // quoting behavior and do it ourselves. - // - // See https://github.com/git-for-windows/git/commit/9e9da23c27650 - windowsVerbatimArguments: true, - // With windowsVerbatimArguments set to true the filename passed to - // spawn won't get quoted by Node.js so he msys2 custom argument parser - // will blow up so we'll just hardcode argv[0] as bash.exe which is - // what it would be set to if a user ran bash.exe in a terminal and it - // was on PATH. The technically correct way would be to set quote it - // as msys2 expects it to be quoted but I'm too deep into Dantes nine - // circles of quoting already. - argv0: 'bash.exe', - } + if (!gitBashPath) { + return undefined } + const { args, quoteCommand } = bash + return { + shell: gitBashPath, + args, + quoteCommand: (cmd, ...args) => quoteArgMsys2(quoteCommand(cmd, ...args)), + // MSYS2 doesn't use the argv it's given, instead it re-parses the + // commandline from GetCommandLineW and it doesn't comform to the + // usual Windows quoting rules. So we need to opt out of Node.js's + // quoting behavior and do it ourselves. + // + // See https://github.com/git-for-windows/git/commit/9e9da23c27650 + windowsVerbatimArguments: true, + // With windowsVerbatimArguments set to true the filename passed to + // spawn won't get quoted by Node.js so he msys2 custom argument parser + // will blow up so we'll just hardcode argv[0] as bash.exe which is + // what it would be set to if a user ran bash.exe in a terminal and it + // was on PATH. The technically correct way would be to set quote it + // as msys2 expects it to be quoted but I'm too deep into Dantes nine + // circles of quoting already. + argv0: 'bash.exe', + } +} +const findCmdShell = async (): Promise => { const { COMSPEC } = process.env // https://github.com/nodejs/node/blob/5f77aebdfb3ea4d60cda79045d29afb244d6bcb1/lib/child_process.js#L660C31-L660C58 const shell = @@ -66,9 +71,38 @@ const findWindowsShell = async (): Promise => { return { shell, args, quoteCommand } } -export const getShell = async (): Promise => { +const findPowerShellShell = async ( + shellKind: Extract +): Promise => { + const pwshPath = await which(`${shellKind}.exe`, { nothrow: true }) + if (!pwshPath) { + return undefined + } + const { args, quoteCommand } = powershell + return { shell: pwshPath, args, quoteCommand } +} + +const findWindowsShell = async ( + shellKind: SupportedHooksEnvShell = 'cmd' +): Promise => { + switch (shellKind) { + case 'g4w-bash': + return findGitBashShell() + case 'powershell': + case 'pwsh': + return findPowerShellShell(shellKind) + case 'cmd': + return findCmdShell() + default: + return assertNever(shellKind, `Unsupported shell kind: ${shellKind}`) + } +} + +export const getShell = async ( + shellKind?: SupportedHooksEnvShell +): Promise => { if (__WIN32__) { - return findWindowsShell() + return findWindowsShell(shellKind) } // For our purposes quoting using bash rules should be sufficient, diff --git a/app/src/lib/hooks/shell-escape.ts b/app/src/lib/hooks/shell-escape.ts index 237ba49a2d0..39d69660309 100644 --- a/app/src/lib/hooks/shell-escape.ts +++ b/app/src/lib/hooks/shell-escape.ts @@ -4,13 +4,17 @@ type Shell = { } // https://github.com/ericcornelissen/shescape/blob/89072ba7de233f81f5553b52098671c94eb9bd0c/src/internal/unix/bash.js#L39 -const bashEscape = (arg: string) => arg - .replace(/[\0\u0008\u001B\u009B]/gu, "") - .replace(/\r(?!\n)/gu, "") - .replace(/'/gu, "'\\''"); +const bashEscape = (arg: string) => + arg + .replace(/[\0\u0008\u001B\u009B]/gu, '') + .replace(/\r(?!\n)/gu, '') + .replace(/'/gu, "'\\''") -const shQuoteCommand = (escapeFn: (arg: string) => string, cmd: string, ...args: string[]) => - [cmd, ...args].map(a => `'${escapeFn(a)}'`).join(' '); +const shQuoteCommand = ( + escapeFn: (arg: string) => string, + cmd: string, + ...args: string[] +) => [cmd, ...args].map(a => `'${escapeFn(a)}'`).join(' ') export const bash: Shell = { args: ['-ilc'], @@ -27,16 +31,40 @@ export const zsh: Shell = { } // https://github.com/ericcornelissen/shescape/blob/89072ba7de233f81f5553b52098671c94eb9bd0c/src/internal/win/cmd.js#L35 -const cmdEscape = (arg: string) => arg - .replace(/[\0\u0008\r\u001B\u009B]/gu, "") - .replace(/\n/gu, " ") - .replace(/"/gu, '""') - .replace(/([%&<>^|])/gu, '"^$1"') - .replace(/(? + arg + .replace(/[\0\u0008\r\u001B\u009B]/gu, '') + .replace(/\n/gu, ' ') + .replace(/"/gu, '""') + .replace(/([%&<>^|])/gu, '"^$1"') + .replace(/(? - `"${[cmd, ...args].map(a => `"${cmdEscape(a)}"`).join(' ')}"` - , + `"${[cmd, ...args].map(a => `"${cmdEscape(a)}"`).join(' ')}"`, +} + +// https://github.com/ericcornelissen/shescape/blob/89072ba7de233f81f5553b52098671c94eb9bd0c/src/internal/win/powershell.js#L50 +const powershellEscape = (arg: string) => { + arg = arg + .replace(/[\0\u0008\u001B\u009B]/gu, '') + .replace(/\r(?!\n)/gu, '') + .replace(/(['‘’‚‛])/gu, '$1$1') + + if (/[\s\u0085]/u.test(arg)) { + arg = arg + .replace(/(? + `& {${[cmd, ...args].map(a => `'${powershellEscape(a)}'`).join(' ')}}`, } diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index ce2c23a135a..d49102c529c 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -3,19 +3,19 @@ import { AddressInfo } from 'net' import { tmpdir } from 'os' import { join } from 'path' import { createProxyProcessServer } from 'process-proxy' -import { enableHooksEnvironment } from '../feature-flag' import type { IGitExecutionOptions } from '../git/core' import { getRepoHooks } from './get-repo-hooks' import { createHooksProxy } from './hooks-proxy' import { getShellEnv } from './get-shell-env' import memoizeOne from 'memoize-one' +import { getHooksEnvEnabled } from './config' export async function withHooksEnv( fn: (env: Record | undefined) => Promise, path: string, opts: IGitExecutionOptions | undefined ): Promise { - if (!opts?.interceptHooks || !enableHooksEnvironment()) { + if (!opts?.interceptHooks || !getHooksEnvEnabled()) { return fn(opts?.env) } diff --git a/app/src/lib/set-state.ts b/app/src/lib/set-state.ts new file mode 100644 index 00000000000..724f692d89b --- /dev/null +++ b/app/src/lib/set-state.ts @@ -0,0 +1,36 @@ +const componentCache = new WeakMap< + React.Component, + Map void> +>() + +/** + * Returns a memoized setter for a specific state key of a React component + * + * This can safely be used in event handlers to avoid creating new + * closures on each render. + */ +export function setState( + component: T, + stateKey: K +) { + let setters = componentCache.get(component) + + if (!setters) { + setters = new Map() + componentCache.set(component, setters) + } + + const cachedSetter = setters.get(stateKey as string) + + if (cachedSetter) { + return cachedSetter + } + + const setter = (value: T['state'][K]) => { + component.setState({ [stateKey]: value }) + } + + setters.set(stateKey as string, setter) + + return setter +} diff --git a/app/src/ui/preferences/git.tsx b/app/src/ui/preferences/git.tsx index 4295a8ec3ef..205292d7a2f 100644 --- a/app/src/ui/preferences/git.tsx +++ b/app/src/ui/preferences/git.tsx @@ -26,10 +26,11 @@ interface IGitProps { readonly selectedTabIndex?: number readonly onSelectedTabIndexChanged: (index: number) => void -} -interface IGitState { - readonly selectedTabIndex: number + readonly onEnableGitHookEnvChanged: (enableGitHookEnv: boolean) => void + readonly onCacheGitHookEnvChanged: (cacheGitHookEnv: boolean) => void + readonly onSelectedShellChanged: (selectedShell: string) => void + readonly enableGitHookEnv: boolean readonly cacheGitHookEnv: boolean readonly selectedShell: string @@ -54,39 +55,31 @@ const windowsShells: ReadonlyArray> = [ }, ] -export class Git extends React.Component { - public constructor(props: IGitProps) { - super(props) - - this.state = { - selectedTabIndex: this.props.selectedTabIndex ?? 0, - enableGitHookEnv: false, // TODO: load from props - cacheGitHookEnv: false, // TODO: load from props - selectedShell: windowsShells[0].key, // TODO: load from props - } +export class Git extends React.Component { + private get selectedTabIndex() { + return this.props.selectedTabIndex ?? 0 } private onTabClicked = (index: number) => { - this.setState({ selectedTabIndex: index }) this.props.onSelectedTabIndexChanged?.(index) } private onEnableGitHookEnvChanged = ( event: React.FormEvent ) => { - this.setState({ enableGitHookEnv: event.currentTarget.checked }) + this.props.onEnableGitHookEnvChanged(event.currentTarget.checked) } private onCacheGitHookEnvChanged = ( event: React.FormEvent ) => { - this.setState({ cacheGitHookEnv: event.currentTarget.checked }) + this.props.onCacheGitHookEnvChanged(event.currentTarget.checked) } private onSelectedShellChanged = ( event: React.FormEvent ) => { - this.setState({ selectedShell: event.currentTarget.value }) + this.props.onSelectedShellChanged(event.currentTarget.value) } private renderHooksSettings() { @@ -96,7 +89,7 @@ export class Git extends React.Component { label="Load Git hook environment variables from shell" ariaDescribedBy="git-hooks-env-description" value={ - this.state.enableGitHookEnv ? CheckboxValue.On : CheckboxValue.Off + this.props.enableGitHookEnv ? CheckboxValue.On : CheckboxValue.Off } onChange={this.onEnableGitHookEnvChanged} /> @@ -108,12 +101,12 @@ export class Git extends React.Component { nvm, rbenv, asdf, etc.

- {this.state.enableGitHookEnv && __WIN32__ && ( + {this.props.enableGitHookEnv && __WIN32__ && ( <> )} diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index 18886493a17..f0a2b209fac 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -48,6 +48,7 @@ import { isValidCustomIntegration, } from '../../lib/custom-integration' import { + defaultGitHookEnvShell, getCacheHooksEnv, getGitHookEnvShell, getHooksEnvEnabled, @@ -493,7 +494,9 @@ export class Preferences extends React.Component< onSelectedShellChanged={this.onSelectedGitHookEnvShellChanged} enableGitHookEnv={this.state.enableGitHookEnv ?? false} cacheGitHookEnv={this.state.cacheGitHookEnv ?? true} - selectedShell={this.state.selectedGitHookEnvShell ?? 'g4w-bash'} + selectedShell={ + this.state.selectedGitHookEnvShell ?? defaultGitHookEnvShell + } /> ) diff --git a/app/styles/ui/_preferences.scss b/app/styles/ui/_preferences.scss index 59b7aefdd10..f8047577f9d 100644 --- a/app/styles/ui/_preferences.scss +++ b/app/styles/ui/_preferences.scss @@ -10,6 +10,10 @@ margin-bottom: var(--spacing); } + .hooks-warning { + margin-bottom: var(--spacing); + } + .git-hooks-support-description, .git-hooks-env-description, .git-hooks-cache-description { From 8049ea4d50ae22611d5b3bf8833449175420fa55 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Wed, 26 Nov 2025 13:54:07 +0000 Subject: [PATCH 177/865] Refactor shell environment loading for hooks Introduces ShellEnvResult type to handle shell environment loading success and failure cases. Updates hooks-proxy and with-hooks-env to use the new result type, improving error handling and user messaging when the shell environment cannot be loaded. Adds memoization and context-aware caching for shell environment retrieval. --- app/src/lib/hooks/get-shell-env.ts | 21 ++++++++++++-- app/src/lib/hooks/hooks-proxy.ts | 45 +++++++++++++++++++++-------- app/src/lib/hooks/with-hooks-env.ts | 37 +++++++++++++++++------- 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/app/src/lib/hooks/get-shell-env.ts b/app/src/lib/hooks/get-shell-env.ts index 69e3987c42b..2c5557485cf 100644 --- a/app/src/lib/hooks/get-shell-env.ts +++ b/app/src/lib/hooks/get-shell-env.ts @@ -3,16 +3,27 @@ import { getShell } from './get-shell' import { spawn } from 'child_process' import { SupportedHooksEnvShell } from './config' +export type ShellEnvResult = + | { + kind: 'success' + env: Record + } + | { + kind: 'failure' + shellKind?: SupportedHooksEnvShell + } + export const getShellEnv = async ( + cwd?: string, shellKind?: SupportedHooksEnvShell -): Promise> => { +): Promise => { const ext = __WIN32__ ? '.exe' : '' const printenvzPath = join(__dirname, `printenvz${ext}`) const shellInfo = await getShell(shellKind) if (!shellInfo) { - throw new Error('Could not determine shell to use for loading environment') + return { kind: 'failure', shellKind } } const { shell, args, quoteCommand, windowsVerbatimArguments, argv0 } = @@ -24,6 +35,7 @@ export const getShellEnv = async ( windowsVerbatimArguments, argv0, stdio: 'pipe', + cwd, }) const chunks: Buffer[] = [] @@ -33,7 +45,10 @@ export const getShellEnv = async ( .on('end', () => { const stdout = Buffer.concat(chunks).toString('utf8') const matches = stdout.matchAll(/([^=]+)=([^\0]*)\0/g) - resolve(Object.fromEntries(Array.from(matches, m => [m[1], m[2]]))) + resolve({ + kind: 'success', + env: Object.fromEntries(Array.from(matches, m => [m[1], m[2]])), + }) }) child.on('error', err => reject(err)) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 3a2c76e1c62..62694f47a3c 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -6,6 +6,8 @@ import { ProcessProxyConnection as Connection } from 'process-proxy' import { pipeline } from 'stream/promises' import type { HookProgress, TerminalOutput } from '../git' import { resolveGitBinary } from 'dugite' +import { ShellEnvResult } from './get-shell-env' +import { shellFriendlyNames } from './config' const hooksUsingStdin = ['post-rewrite'] const ignoredOnFailureHooks = [ @@ -43,15 +45,16 @@ const debug = (message: string, error?: Error) => { } const exitWithMessage = (conn: Connection, msg: string, exitCode = 0) => { - return new Promise(resolve => { - conn.stderr.end(`${msg}\n`) - conn.exit(exitCode).then(resolve, err => { - debug( - `failed to exit proxy: ${ - err instanceof Error ? err.message : String(err) - }` - ) - resolve() + return new Promise(async resolve => { + conn.stderr.write(`${msg}\n`, () => { + conn.exit(exitCode).then(resolve, err => { + debug( + `failed to exit proxy: ${ + err instanceof Error ? err.message : String(err) + }` + ) + resolve() + }) }) }) } @@ -61,7 +64,7 @@ const exitWithError = (conn: Connection, msg: string, exitCode = 1) => export const createHooksProxy = ( tmpDir: string, - getShellEnv: () => Promise>, + getShellEnv: (cwd: string) => Promise, onHookProgress?: (progress: HookProgress) => void, onHookFailure?: ( hookName: string, @@ -117,7 +120,25 @@ export const createHooksProxy = ( const terminalOutput: Buffer[] = [] const gitPath = resolveGitBinary(resolve(__dirname, 'git')) - const shellEnv = await getShellEnv() + const shellEnv = await getShellEnv(proxyCwd) + + if (shellEnv.kind === 'failure') { + let errMsg = `Failed to load shell environment for hook ${hookName}.` + debug(errMsg) + + if (shellEnv.shellKind) { + const friendlyName = shellFriendlyNames[shellEnv.shellKind] + if (shellEnv.shellKind === 'git-bash') { + errMsg += `\n${friendlyName} not found. Please ensure Git for Windows is installed and added to your PATH.` + } else { + errMsg += `\n${friendlyName} not found. Please ensure it's installed and added to your PATH.` + } + } + + errMsg += '\n\nConfigure the shell to use in Preferences > Git > Hooks.' + + return exitWithError(conn, errMsg) + } const { code, signal } = await new Promise<{ code: number | null @@ -127,7 +148,7 @@ export const createHooksProxy = ( const child = spawn(gitPath, args, { cwd: proxyCwd, - env: { ...shellEnv, ...safeEnv }, + env: { ...shellEnv.env, ...safeEnv }, signal: abortController.signal, }) .on('close', (code, signal) => resolve({ code, signal })) diff --git a/app/src/lib/hooks/with-hooks-env.ts b/app/src/lib/hooks/with-hooks-env.ts index cf8bcc81bad..cac97a49d43 100644 --- a/app/src/lib/hooks/with-hooks-env.ts +++ b/app/src/lib/hooks/with-hooks-env.ts @@ -8,7 +8,23 @@ import { getRepoHooks } from './get-repo-hooks' import { createHooksProxy } from './hooks-proxy' import { getShellEnv } from './get-shell-env' import memoizeOne from 'memoize-one' -import { getGitHookEnvShell, getHooksEnvEnabled } from './config' +import { + getCacheHooksEnv, + getGitHookEnvShell, + getHooksEnvEnabled, + SupportedHooksEnvShell, +} from './config' + +const memoizedGetShellEnv = memoizeOne( + async (shellKind: SupportedHooksEnvShell, cwd: string, cacheKey: string) => { + const shellEnvStartTime = Date.now() + const shellEnv = await getShellEnv(cwd, shellKind) + log.debug( + `hooks: loaded shell environment in ${Date.now() - shellEnvStartTime}ms` + ) + return shellEnv + } +) export async function withHooksEnv( fn: (env: Record | undefined) => Promise, @@ -25,15 +41,6 @@ export async function withHooksEnv( return fn(opts?.env) } - const memoizedGetShellEnv = memoizeOne(async () => { - const shellEnvStartTime = Date.now() - const shellEnv = await getShellEnv(getGitHookEnvShell()) - log.debug( - `hooks: loaded shell environment in ${Date.now() - shellEnvStartTime}ms` - ) - return shellEnv - }) - const ext = __WIN32__ ? '.exe' : '' const processProxyPath = join(__dirname, `process-proxy${ext}`) @@ -41,7 +48,15 @@ export async function withHooksEnv( const tmpHooksDir = await mkdtemp(join(tmpdir(), 'desktop-git-hooks-')) const hooksProxy = createHooksProxy( tmpHooksDir, - memoizedGetShellEnv, + cwd => + memoizedGetShellEnv( + getGitHookEnvShell(), + cwd, + // We always cache environment per token (i.e. per operation, e.g commit, apply, etc) + // but we can optionally cache it over multiple operations in the same repository if the user + // has enabled that setting. + getCacheHooksEnv() ? 'global' : token + ), opts?.onHookProgress, opts?.onHookFailure ) From 3f94f5b7d71f1bfb07322729620f3a8c45734c0a Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 27 Nov 2025 10:44:02 +0100 Subject: [PATCH 178/865] Allow bypassing commit hooks --- app/src/lib/app-state.ts | 8 +++ app/src/lib/git/commit.ts | 5 ++ app/src/lib/menu-item.ts | 4 +- app/src/lib/stores/app-store.ts | 22 ++++++ app/src/lib/stores/repository-state-cache.ts | 2 + .../main-process/menu/build-context-menu.ts | 1 + app/src/ui/app.tsx | 13 ++++ app/src/ui/changes/changes-list.tsx | 10 +++ app/src/ui/changes/commit-message.tsx | 68 ++++++++++++++++++- app/src/ui/changes/filter-changes-list.tsx | 10 +++ app/src/ui/changes/sidebar.tsx | 10 +++ .../commit-message/commit-message-dialog.tsx | 10 +++ app/src/ui/dispatcher/dispatcher.ts | 7 ++ app/src/ui/repository.tsx | 10 +++ app/styles/ui/changes/_commit-message.scss | 18 ++--- 15 files changed, 181 insertions(+), 17 deletions(-) diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index abc42cae942..b481ab45415 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -589,6 +589,14 @@ export interface IRepositoryState { /** State associated with a multi commit operation such as rebase, * cherry-pick, squash, reorder... */ readonly multiCommitOperationState: IMultiCommitOperationState | null + + /** + * Whether there are any hooks in the repository that could be + * skipped during commit with the --no-verify flag + */ + readonly hasCommitHooks: boolean + + readonly skipCommitHooks: boolean } export interface IBranchesState { diff --git a/app/src/lib/git/commit.ts b/app/src/lib/git/commit.ts index 627cc5a3ec5..31d9ffc9ee1 100644 --- a/app/src/lib/git/commit.ts +++ b/app/src/lib/git/commit.ts @@ -30,6 +30,7 @@ export async function createCommit( terminalOutput: TerminalOutput ) => Promise<'abort' | 'ignore'> onTerminalOutputAvailable?: TerminalOutputCallback + skipCommitHooks?: boolean } ): Promise { // Clear the staging area, our diffs reflect the difference between the @@ -45,6 +46,10 @@ export async function createCommit( args.push('--amend') } + if (options?.skipCommitHooks) { + args.push('--no-verify') + } + const result = await git( ['commit', ...args], repository.path, diff --git a/app/src/lib/menu-item.ts b/app/src/lib/menu-item.ts index 7d3d97918bc..5f4ff3b1238 100644 --- a/app/src/lib/menu-item.ts +++ b/app/src/lib/menu-item.ts @@ -8,7 +8,9 @@ export interface IMenuItem { readonly action?: () => void /** The type of item. */ - readonly type?: 'separator' + readonly type?: 'separator' | 'checkbox' + + readonly checked?: boolean /** Is the menu item enabled? Defaults to true. */ readonly enabled?: boolean diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 56623fc4969..ca7d75f3215 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -349,6 +349,7 @@ import { } from '../custom-integration' import { updateStore } from '../../ui/lib/update-store' import { BypassReasonType } from '../../ui/secret-scanning/bypass-push-protection-dialog' +import { getRepoHooks } from '../hooks/get-repo-hooks' const LastSelectedRepositoryIDKey = 'last-selected-repository-id' @@ -3340,6 +3341,7 @@ export class AppStore extends TypedBaseStore { subscribeToCommitOutput, })) }, + skipCommitHooks: state.skipCommitHooks, }).catch(err => (aborted ? undefined : Promise.reject(err))) }, { gitContext: { kind: 'commit' }, repository } @@ -3635,6 +3637,7 @@ export class AppStore extends TypedBaseStore { gitStore.updateLastFetched(), gitStore.loadStashEntries(), this._refreshAuthor(repository), + this._refreshHasCommitHooks(repository), refreshSectionPromise, ]) @@ -3890,6 +3893,25 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } + public _updateSkipCommitHooks( + repository: Repository, + skipCommitHooks: boolean + ): void { + this.repositoryStateCache.update(repository, () => ({ skipCommitHooks })) + this.emitUpdate() + } + + private async _refreshHasCommitHooks(repository: Repository): Promise { + const hooks = ['pre-commit', 'commit-msg'] + // Break early if we find either one of the hooks + for await (const {} of getRepoHooks(repository.path, hooks)) { + const hasCommitHooks = true + this.repositoryStateCache.update(repository, () => ({ hasCommitHooks })) + this.emitUpdate() + return + } + } + /** This shouldn't be called directly. See `Dispatcher`. */ public async _showPopup(popup: Popup): Promise { // Always close the app menu when showing a pop up. This is only diff --git a/app/src/lib/stores/repository-state-cache.ts b/app/src/lib/stores/repository-state-cache.ts index 76bd1a7dd6f..a15e9b2765c 100644 --- a/app/src/lib/stores/repository-state-cache.ts +++ b/app/src/lib/stores/repository-state-cache.ts @@ -373,5 +373,7 @@ function getInitialRepositoryState(): IRepositoryState { revertProgress: null, multiCommitOperationUndoState: null, multiCommitOperationState: null, + hasCommitHooks: false, + skipCommitHooks: false, } } diff --git a/app/src/main-process/menu/build-context-menu.ts b/app/src/main-process/menu/build-context-menu.ts index 0a673a277ea..d17ee8be156 100644 --- a/app/src/main-process/menu/build-context-menu.ts +++ b/app/src/main-process/menu/build-context-menu.ts @@ -83,6 +83,7 @@ function buildRecursiveContextMenu( new MenuItem({ label: item.label, type: item.type, + checked: item.checked, enabled: item.enabled, role: item.role, click: () => actionFn(indices), diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 57e13cafaeb..fac3edb5eb8 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -2178,6 +2178,9 @@ export class App extends React.Component { onSubmitCommitMessage={popup.onSubmitCommitMessage} repositoryAccount={repositoryAccount} accounts={this.state.accounts} + hasCommitHooks={repositoryState.hasCommitHooks} + skipCommitHooks={repositoryState.skipCommitHooks} + onSkipCommitHooksChanged={this.onSkipCommitHooksChanged} /> ) case PopupType.MultiCommitOperation: { @@ -2602,6 +2605,13 @@ export class App extends React.Component { } } + private onSkipCommitHooksChanged = ( + repository: Repository, + skipCommitHooks: boolean + ) => { + this.props.dispatcher.updateSkipCommitHooks(repository, skipCommitHooks) + } + private onSecretDelegatedBypassLinkClick = () => { this.props.dispatcher.incrementMetric( 'secretsDetectedOnPushDelegatedBypassLinkClickedCount' @@ -3453,6 +3463,9 @@ export class App extends React.Component { shouldShowGenerateCommitMessageCallOut={ !this.state.commitMessageGenerationButtonClicked } + hasCommitHooks={selectedState.state.hasCommitHooks} + skipCommitHooks={selectedState.state.skipCommitHooks} + onSkipCommitHooksChanged={this.onSkipCommitHooksChanged} /> ) } else if (selectedState.type === SelectionType.CloningRepository) { diff --git a/app/src/ui/changes/changes-list.tsx b/app/src/ui/changes/changes-list.tsx index 6f38aaab071..7e477c43b8f 100644 --- a/app/src/ui/changes/changes-list.tsx +++ b/app/src/ui/changes/changes-list.tsx @@ -233,6 +233,13 @@ interface IChangesListProps { readonly showCommitLengthWarning: boolean readonly accounts: ReadonlyArray + + readonly hasCommitHooks: boolean + readonly skipCommitHooks: boolean + readonly onSkipCommitHooksChanged: ( + repository: Repository, + skipCommitHooks: boolean + ) => void } interface IChangesState { @@ -880,6 +887,9 @@ export class ChangesList extends React.Component< onStopAmending={this.onStopAmending} onShowCreateForkDialog={this.onShowCreateForkDialog} accounts={this.props.accounts} + hasCommitHooks={this.props.hasCommitHooks} + skipCommitHooks={this.props.skipCommitHooks} + onSkipCommitHooksChanged={this.props.onSkipCommitHooksChanged} /> ) } diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index d216a34d152..4c18037fccd 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -62,7 +62,10 @@ import { formatCommitMessage } from '../../lib/format-commit-message' import { useRepoRulesLogic } from '../../lib/helpers/repo-rules' import { isDotCom } from '../../lib/endpoint-capabilities' import { WorkingDirectoryFileChange } from '../../models/status' -import { enableCommitMessageGeneration } from '../../lib/feature-flag' +import { + enableCommitMessageGeneration, + enableHooksEnvironment, +} from '../../lib/feature-flag' import { AriaLiveContainer } from '../accessibility/aria-live-container' import { HookProgress } from '../../lib/git' import { assertNever } from '../../lib/fatal-error' @@ -198,6 +201,13 @@ interface ICommitMessageProps { /** Optional to add an id to a message that should be provided as an aria * description of the submit button */ readonly submitButtonAriaDescribedBy?: string + + readonly hasCommitHooks: boolean + readonly skipCommitHooks: boolean + readonly onSkipCommitHooksChanged: ( + repository: Repository, + skipCommitHooks: boolean + ) => void } interface ICommitMessageState { @@ -992,6 +1002,51 @@ export class CommitMessage extends React.Component< ) } + private renderCommitOptionsButton() { + if (!this.isCommitOptionsButtonEnabled) { + return null + } + + const ariaLabel = 'Configure commit options' + + return ( + <> + {(this.isCoAuthorInputEnabled || this.isCopilotButtonEnabled) && ( +
+ )} + + + ) + } + + private onCommitOptionsButtonClick = ( + e: React.MouseEvent + ) => { + e.preventDefault() + showContextualMenu([ + { + type: 'checkbox', + checked: this.props.skipCommitHooks, + label: __DARWIN__ + ? 'Bypass Hooks (--no-verify)' + : 'Bypass hooks (--no-verify)', + action: () => { + this.props.onSkipCommitHooksChanged( + this.props.repository, + !this.props.skipCommitHooks + ) + }, + }, + ]) + } + private renderCoAuthorToggleButton() { if (this.props.repository.gitHubRepository === null) { return null @@ -1080,11 +1135,19 @@ export class CommitMessage extends React.Component< ) } + private get isCommitOptionsButtonEnabled() { + return enableHooksEnvironment() && this.props.hasCommitHooks + } + /** * Whether or not there's anything to render in the action bar */ private get isActionBarEnabled() { - return this.isCoAuthorInputEnabled || this.isCopilotButtonEnabled + return ( + this.isCoAuthorInputEnabled || + this.isCopilotButtonEnabled || + this.isCommitOptionsButtonEnabled + ) } private renderActionBar() { @@ -1102,6 +1165,7 @@ export class CommitMessage extends React.Component<
{this.renderCoAuthorToggleButton()} {this.renderCopilotButton()} + {this.renderCommitOptionsButton()}
) } diff --git a/app/src/ui/changes/filter-changes-list.tsx b/app/src/ui/changes/filter-changes-list.tsx index 108654457f7..209ab60ebc3 100644 --- a/app/src/ui/changes/filter-changes-list.tsx +++ b/app/src/ui/changes/filter-changes-list.tsx @@ -222,6 +222,13 @@ interface IFilterChangesListProps { /** Whether or not to show the changes filter */ readonly showChangesFilter: boolean + + readonly hasCommitHooks: boolean + readonly skipCommitHooks: boolean + readonly onSkipCommitHooksChanged: ( + repository: Repository, + skipCommitHooks: boolean + ) => void } interface IFilterChangesListState { @@ -985,6 +992,9 @@ export class FilterChangesList extends React.Component< accounts={this.props.accounts} onSuccessfulCommitCreated={this.onSuccessfulCommitCreated} submitButtonAriaDescribedBy={'hidden-changes-warning'} + hasCommitHooks={this.props.hasCommitHooks} + skipCommitHooks={this.props.skipCommitHooks} + onSkipCommitHooksChanged={this.props.onSkipCommitHooksChanged} /> ) } diff --git a/app/src/ui/changes/sidebar.tsx b/app/src/ui/changes/sidebar.tsx index 258cb1b701c..3d989239ad5 100644 --- a/app/src/ui/changes/sidebar.tsx +++ b/app/src/ui/changes/sidebar.tsx @@ -96,6 +96,13 @@ interface IChangesSidebarProps { /** Whether or not to show the changes filter */ readonly showChangesFilter: boolean + + readonly hasCommitHooks: boolean + readonly skipCommitHooks: boolean + readonly onSkipCommitHooksChanged: ( + repository: Repository, + skipCommitHooks: boolean + ) => void } export class ChangesSidebar extends React.Component { @@ -466,6 +473,9 @@ export class ChangesSidebar extends React.Component { accounts={this.props.accounts} fileListFilter={this.props.changes.fileListFilter} showChangesFilter={this.props.showChangesFilter} + hasCommitHooks={this.props.hasCommitHooks} + skipCommitHooks={this.props.skipCommitHooks} + onSkipCommitHooksChanged={this.props.onSkipCommitHooksChanged} /> {this.renderUndoCommit(rebaseConflictState)}
diff --git a/app/src/ui/commit-message/commit-message-dialog.tsx b/app/src/ui/commit-message/commit-message-dialog.tsx index 684b50890ec..5fcb7d9816b 100644 --- a/app/src/ui/commit-message/commit-message-dialog.tsx +++ b/app/src/ui/commit-message/commit-message-dialog.tsx @@ -95,6 +95,13 @@ interface ICommitMessageDialogProps { readonly repositoryAccount: Account | null readonly accounts: ReadonlyArray + + readonly hasCommitHooks: boolean + readonly skipCommitHooks: boolean + readonly onSkipCommitHooksChanged: ( + repository: Repository, + skipCommitHooks: boolean + ) => void } interface ICommitMessageDialogState { @@ -167,6 +174,9 @@ export class CommitMessageDialog extends React.Component< isCommitting={false} hookProgress={null} onShowCommitProgress={undefined} + hasCommitHooks={this.props.hasCommitHooks} + skipCommitHooks={this.props.skipCommitHooks} + onSkipCommitHooksChanged={this.props.onSkipCommitHooksChanged} />
diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index e1c17b39cab..1e7965446a5 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -221,6 +221,13 @@ export class Dispatcher { return this.appStore._updateRepositoryMissing(repository, missing) } + public updateSkipCommitHooks( + repository: Repository, + skipCommitHooks: boolean + ) { + this.appStore._updateSkipCommitHooks(repository, skipCommitHooks) + } + /** Load the next batch of history for the repository. */ public loadNextCommitBatch(repository: Repository): Promise { return this.appStore._loadNextCommitBatch(repository) diff --git a/app/src/ui/repository.tsx b/app/src/ui/repository.tsx index 597aa3f7da7..f0666ede5f1 100644 --- a/app/src/ui/repository.tsx +++ b/app/src/ui/repository.tsx @@ -114,6 +114,13 @@ interface IRepositoryViewProps { /** Whether or not to show the changes filter */ readonly showChangesFilter: boolean + + readonly hasCommitHooks: boolean + readonly skipCommitHooks: boolean + readonly onSkipCommitHooksChanged: ( + repository: Repository, + skipCommitHooks: boolean + ) => void } interface IRepositoryViewState { @@ -293,6 +300,9 @@ export class RepositoryView extends React.Component< commitSpellcheckEnabled={this.props.commitSpellcheckEnabled} showCommitLengthWarning={this.props.showCommitLengthWarning} showChangesFilter={this.props.showChangesFilter} + hasCommitHooks={this.props.hasCommitHooks} + skipCommitHooks={this.props.skipCommitHooks} + onSkipCommitHooksChanged={this.props.onSkipCommitHooksChanged} /> ) } diff --git a/app/styles/ui/changes/_commit-message.scss b/app/styles/ui/changes/_commit-message.scss index 747aa9c7ab6..aaa1ffd8cee 100644 --- a/app/styles/ui/changes/_commit-message.scss +++ b/app/styles/ui/changes/_commit-message.scss @@ -166,19 +166,8 @@ width: auto !important; } - .call-to-action-bubble { - font-weight: var(--font-weight-semibold); - display: inline-block; - font-size: var(--font-size-xs); - border: 1px solid var(--call-to-action-bubble-border-color); - color: var(--call-to-action-bubble-color); - padding: 1px 5px; - border-radius: var(--border-radius); - margin-left: var(--spacing-third); - cursor: pointer; - } - - .co-authors-toggle { + .co-authors-toggle, + .commit-options-button { color: var(--text-secondary-color); &:hover { @@ -290,7 +279,8 @@ } .co-authors-toggle, - .copilot-button { + .copilot-button, + .commit-options-button { // button reset styles border: none; border-radius: 0px; From 3892b5eb97772554567153aa1d1f015d0711715a Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 27 Nov 2025 11:05:50 +0100 Subject: [PATCH 179/865] Update commit options button styling logic The commit options button now conditionally applies the 'default-options' class based on the skipCommitHooks prop. Corresponding SCSS changes ensure the button uses the appropriate text color for default and non-default states. --- app/src/ui/changes/commit-message.tsx | 4 +++- app/styles/ui/changes/_commit-message.scss | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index 4c18037fccd..3a66c978bf6 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -1015,7 +1015,9 @@ export class CommitMessage extends React.Component<
)}
diff --git a/app/src/ui/commit-message/commit-message-dialog.tsx b/app/src/ui/commit-message/commit-message-dialog.tsx index 5fcb7d9816b..7319a56190f 100644 --- a/app/src/ui/commit-message/commit-message-dialog.tsx +++ b/app/src/ui/commit-message/commit-message-dialog.tsx @@ -13,7 +13,7 @@ import { Author, UnknownAuthor } from '../../models/author' import { CommitMessage } from '../changes/commit-message' import noop from 'lodash/noop' import { Popup } from '../../models/popup' -import { Foldout } from '../../lib/app-state' +import { CommitOptions, Foldout } from '../../lib/app-state' import { Account } from '../../models/account' import { RepoRulesInfo } from '../../models/repo-rules' import { IAheadBehind } from '../../models/branch' @@ -98,9 +98,9 @@ interface ICommitMessageDialogProps { readonly hasCommitHooks: boolean readonly skipCommitHooks: boolean - readonly onSkipCommitHooksChanged: ( + readonly onUpdateCommitOptions: ( repository: Repository, - skipCommitHooks: boolean + options: CommitOptions ) => void } @@ -176,7 +176,7 @@ export class CommitMessageDialog extends React.Component< onShowCommitProgress={undefined} hasCommitHooks={this.props.hasCommitHooks} skipCommitHooks={this.props.skipCommitHooks} - onSkipCommitHooksChanged={this.props.onSkipCommitHooksChanged} + onUpdateCommitOptions={this.props.onUpdateCommitOptions} />
diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 1e7965446a5..e9f66b02b47 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -22,6 +22,7 @@ import { CherryPickConflictState, MultiCommitOperationConflictState, IMultiCommitOperationState, + CommitOptions, } from '../../lib/app-state' import { assertNever, fatalError } from '../../lib/fatal-error' import { @@ -221,11 +222,8 @@ export class Dispatcher { return this.appStore._updateRepositoryMissing(repository, missing) } - public updateSkipCommitHooks( - repository: Repository, - skipCommitHooks: boolean - ) { - this.appStore._updateSkipCommitHooks(repository, skipCommitHooks) + public updateCommitOptions(repository: Repository, options: CommitOptions) { + this.appStore._updateCommitOptions(repository, options) } /** Load the next batch of history for the repository. */ diff --git a/app/src/ui/repository.tsx b/app/src/ui/repository.tsx index f0666ede5f1..2acf590ff52 100644 --- a/app/src/ui/repository.tsx +++ b/app/src/ui/repository.tsx @@ -15,6 +15,7 @@ import { RepositorySectionTab, ChangesSelectionKind, IConstrainedValue, + CommitOptions, } from '../lib/app-state' import { Dispatcher } from './dispatcher' import { IssuesStore, GitHubUserStore } from '../lib/stores' @@ -117,9 +118,9 @@ interface IRepositoryViewProps { readonly hasCommitHooks: boolean readonly skipCommitHooks: boolean - readonly onSkipCommitHooksChanged: ( + readonly onUpdateCommitOptions: ( repository: Repository, - skipCommitHooks: boolean + options: CommitOptions ) => void } @@ -302,7 +303,7 @@ export class RepositoryView extends React.Component< showChangesFilter={this.props.showChangesFilter} hasCommitHooks={this.props.hasCommitHooks} skipCommitHooks={this.props.skipCommitHooks} - onSkipCommitHooksChanged={this.props.onSkipCommitHooksChanged} + onUpdateCommitOptions={this.props.onUpdateCommitOptions} /> ) } From 7d8c39c266c3b9d95c11682a2b7a51d34acc96e7 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 27 Nov 2025 11:14:01 +0100 Subject: [PATCH 181/865] Let hooks know they're being run in Desktop --- app/src/lib/hooks/hooks-proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/hooks/hooks-proxy.ts b/app/src/lib/hooks/hooks-proxy.ts index 62694f47a3c..e993447a3e8 100644 --- a/app/src/lib/hooks/hooks-proxy.ts +++ b/app/src/lib/hooks/hooks-proxy.ts @@ -148,7 +148,7 @@ export const createHooksProxy = ( const child = spawn(gitPath, args, { cwd: proxyCwd, - env: { ...shellEnv.env, ...safeEnv }, + env: { ...shellEnv.env, ...safeEnv, GITHUB_DESKTOP: '1' }, signal: abortController.signal, }) .on('close', (code, signal) => resolve({ code, signal })) From 9a77424c7d5b0eeab5fe54edf98e5be0305f9605 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Thu, 27 Nov 2025 11:32:40 +0100 Subject: [PATCH 182/865] Update enableHooksEnvironment to use beta features Changed enableHooksEnvironment to reference enableBetaFeatures instead of enableDevelopmentFeatures for consistency with other feature flags. --- app/src/lib/feature-flag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index 5325758459f..738eea5bc79 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -121,4 +121,4 @@ export function enableAccessibleListToolTips(): boolean { return enableBetaFeatures() } -export const enableHooksEnvironment = enableDevelopmentFeatures +export const enableHooksEnvironment = enableBetaFeatures From 299febeaf3fe3579ffa3a2480ce9ca05a0ce7c38 Mon Sep 17 00:00:00 2001 From: Dylan Ravel <48571264+DylanDevelops@users.noreply.github.com> Date: Thu, 27 Nov 2025 08:43:42 -0700 Subject: [PATCH 183/865] Update app/src/ui/toolbar/branch-dropdown.tsx Co-authored-by: Markus Olsson --- app/src/ui/toolbar/branch-dropdown.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index 02bafbc5ef7..78613df9eb3 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -309,9 +309,11 @@ export class BranchDropdown extends React.Component { name: tip.branch.name, isLocal: tip.branch.type === BranchType.Local, onRenameBranch: this.onRenameBranch, - onViewBranchOnGitHub: tip.branch.upstreamRemoteName - ? this.onViewBranchOnGitHub - : undefined, + onViewBranchOnGitHub: + isRepositoryWithGitHubRepository(this.props.repository) && + tip.branch.upstreamRemoteName + ? this.onViewBranchOnGitHub + : undefined, onViewPullRequestOnGitHub: this.props.currentPullRequest ? this.onViewPullRequestOnGithub : undefined, From aadf1e23f5f58f8c050af0f332a7064a4195c723 Mon Sep 17 00:00:00 2001 From: Dylan Ravel <48571264+DylanDevelops@users.noreply.github.com> Date: Thu, 27 Nov 2025 08:43:49 -0700 Subject: [PATCH 184/865] Update app/src/ui/toolbar/branch-dropdown.tsx Co-authored-by: Markus Olsson --- app/src/ui/toolbar/branch-dropdown.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index 78613df9eb3..7ab92241f13 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -355,7 +355,13 @@ export class BranchDropdown extends React.Component { return } - const branchName = tip.branch.upstreamWithoutRemote ?? tip.branch.name + if (!tip.branch.upstreamWithoutRemote) { + return + } + + const url = `${gitHubRepository.htmlURL}/tree/${encodeURIComponent( + tip.branch.upstreamWithoutRemote + )}` const url = `${gitHubRepository.htmlURL}/tree/${encodeURIComponent( branchName )}` From bca33e14ed0cc23802baa2b66767a49e029803be Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Thu, 27 Nov 2025 08:49:11 -0700 Subject: [PATCH 185/865] Fix duplicate URL assignment in BranchDropdown Removed redundant assignment of the 'url' variable in the BranchDropdown component to ensure only the correct URL is used when opening a branch in the browser. --- app/src/ui/toolbar/branch-dropdown.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index 7ab92241f13..ff74be26c0d 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -2,7 +2,10 @@ import * as React from 'react' import { Dispatcher } from '../dispatcher' import * as octicons from '../octicons/octicons.generated' import { OcticonSymbol, syncClockwise } from '../octicons' -import { Repository } from '../../models/repository' +import { + isRepositoryWithGitHubRepository, + Repository, +} from '../../models/repository' import { Resizable } from '../resizable' import { TipState } from '../../models/tip' import { ToolbarDropdown, DropdownState } from './dropdown' @@ -362,9 +365,6 @@ export class BranchDropdown extends React.Component { const url = `${gitHubRepository.htmlURL}/tree/${encodeURIComponent( tip.branch.upstreamWithoutRemote )}` - const url = `${gitHubRepository.htmlURL}/tree/${encodeURIComponent( - branchName - )}` this.props.dispatcher.openInBrowser(url) } From cd33d97d493f2f86adb83dd488004caec1336a1f Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 28 Nov 2025 10:28:49 +0100 Subject: [PATCH 186/865] Refresh repository after commit failure Adds a repository refresh when a commit fails to ensure the UI accurately reflects the repository state. Addresses issue #21229. --- app/src/lib/stores/app-store.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 7180325c3fd..760ca92ce71 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -3340,6 +3340,11 @@ export class AppStore extends TypedBaseStore { result, state.commitToAmend ) + } else { + // The commit failed, but we should still refresh to ensure we + // accurately reflect the repository state post failure. See + // https://github.com/desktop/desktop/issues/21229 + this._refreshRepository(repository) } return result !== undefined From 69764287d2ecf030b20f33c0f0137ee47e4e91a7 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 28 Nov 2025 12:01:38 +0100 Subject: [PATCH 187/865] Improve branch renaming to handle case-only changes Enhanced the renameBranch function to retry with force (-M) when a case-only branch rename fails due to case-insensitive filesystems. Added getBranchNames utility to verify branch existence and prevent unsafe renames. --- app/src/lib/git/branch.ts | 59 ++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/app/src/lib/git/branch.ts b/app/src/lib/git/branch.ts index d1988ee3d0c..2bf55079b4f 100644 --- a/app/src/lib/git/branch.ts +++ b/app/src/lib/git/branch.ts @@ -1,4 +1,4 @@ -import { git } from './core' +import { coerceToString, git, isGitError } from './core' import { Repository } from '../../models/repository' import { Branch } from '../../models/branch' import { formatAsLocalRef } from './refs' @@ -36,17 +36,62 @@ export async function createBranch( await git(args, repository.path, 'createBranch') } +export const getBranchNames = ({ path }: Repository): Promise => { + const parser = createForEachRefParser({ name: '%(refname:short)' }) + return git(['branch', ...parser.formatArgs], path, 'getBranchNames').then(x => + parser.parse(x.stdout).map(b => b.name) + ) +} + /** Rename the given branch to a new name. */ export async function renameBranch( repository: Repository, branch: Branch, - newName: string + newName: string, + force?: boolean ): Promise { - await git( - ['branch', '-m', branch.nameWithoutRemote, newName], - repository.path, - 'renameBranch' - ) + try { + await git( + ['branch', force ? '-m' : '-M', branch.nameWithoutRemote, newName], + repository.path, + 'renameBranch' + ) + } catch (error) { + // If we failed to rename and the branch name only differs by case, we + // we'll try again with the -M flag to force the rename. See + // https://github.com/desktop/desktop/issues/21320 + if ( + // Only retry if the caller hasn't explicitly asked us to force the rename + force === undefined && + isGitError(error) && + error.result.gitError === DugiteError.BranchAlreadyExists + ) { + const stderr = coerceToString(error.result.stderr) + const m = /fatal: a branch named '(.+?)' already exists/.exec(stderr) + + if (m && m[1].toLowerCase() === newName.toLowerCase()) { + // At this point we're almost certain that we are dealing with a + // case-only rename on a case insensitive filesystem, but we can't + // be 100% sure, NTFS can be configured to be case sensitive and macOS + // might have case sensitive file systems mounted so we have to list + // all branches and check the names. + return ( + getBranchNames(repository) + // Throw the original error if we fail to get the branch names + .catch(() => Promise.reject(error)) + .then(names => + // If we find the new name in the list of branches we can't + // safely assume it's a case-only rename and have to + // propagate the original error, otherwise try again with -M + names.includes(newName) + ? Promise.reject(error) + : renameBranch(repository, branch, newName, true) + ) + ) + } + } + throw error + } } /** From e57da234c30f307be01faa866d5f963153ae9059 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Fri, 28 Nov 2025 12:06:16 +0100 Subject: [PATCH 188/865] One day I will learn how to computer --- app/src/lib/git/branch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/git/branch.ts b/app/src/lib/git/branch.ts index 2bf55079b4f..930f54623a5 100644 --- a/app/src/lib/git/branch.ts +++ b/app/src/lib/git/branch.ts @@ -52,7 +52,7 @@ export async function renameBranch( ): Promise { try { await git( - ['branch', force ? '-m' : '-M', branch.nameWithoutRemote, newName], + ['branch', force ? '-M' : '-m', branch.nameWithoutRemote, newName], repository.path, 'renameBranch' ) From 769a8eaa77410b65795e9fd7c42ef15685ab37aa Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:38:52 -0500 Subject: [PATCH 189/865] Refresh selected repo after account change Adds a method to refresh the GitHub repository information for the currently selected repository when the active account changes. This ensures permission and repository data are up to date after signing in or out. --- app/src/lib/stores/app-store.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 7180325c3fd..faa0d375077 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -910,6 +910,8 @@ export class AppStore extends TypedBaseStore { updateAccounts(endpointTokens) + this.refreshSelectedRepositoryAfterAccountChange() + this.emitUpdate() }) this.accountsStore.onDidError(error => this.emitError(error)) @@ -4333,6 +4335,25 @@ export class AppStore extends TypedBaseStore { return freshRepo } + /** + * Refreshes the GitHub repository information for the currently selected + * repository when the active account changes. This ensures that permission + * information is updated after signing in/out. + */ + private async refreshSelectedRepositoryAfterAccountChange() { + const repository = this.selectedRepository + + if (repository === null || repository instanceof CloningRepository) { + return + } + + if (!isRepositoryWithGitHubRepository(repository)) { + return + } + + await this.repositoryWithRefreshedGitHubRepository(repository) + } + private async updateBranchProtectionsFromAPI(repository: Repository) { if (repository.gitHubRepository === null) { return From 9c33461a13c82e0acff35a453783196b14516a70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 09:37:33 +0000 Subject: [PATCH 190/865] Address feedback: simplify dev workflow, add CoC link, reduce dev tips Co-authored-by: niik <634063+niik@users.noreply.github.com> --- .github/copilot-instructions.md | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aa14592b9d5..e0221c02bfd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -97,15 +97,6 @@ yarn # Development build yarn build:dev - -# Start the application (changes compile in background, reload with Ctrl/Cmd+Alt+R) -yarn start - -# Production build -yarn build:prod - -# Clean rebuild -yarn clean-slate && yarn build:dev ``` ### Testing @@ -189,9 +180,6 @@ yarn prettier --write - **Use the Dispatcher**: Route state-changing interactions through the `Dispatcher` to the `AppStore` - **Avoid direct AppStore manipulation**: Methods in AppStore should be called via Dispatcher - **Leverage TypeScript**: Use type system for compile-time verification of exhaustiveness and correctness -- **React Dev Tools**: Automatically available in development mode -- **Debugging**: Use Chrome Dev Tools (View → Toggle Developer Tools) -- **Hot reload**: Press Ctrl/Cmd+Alt+R to reload the app after changes ## Contributing @@ -203,7 +191,7 @@ yarn prettier --write ## Code of Conduct -This project adheres to the Contributor Covenant Code of Conduct. All interactions must be respectful and professional. +This project adheres to the Contributor Covenant [Code of Conduct](../CODE_OF_CONDUCT.md). All interactions must be respectful and professional. ## Resources From b10bdeb509165514a2741290c2454c060bcf4105 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:50:57 -0500 Subject: [PATCH 191/865] Update code signing account and signing tool --- .github/workflows/ci.yml | 2 +- script/package.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feb45c52b1e..60a5c690d34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,7 +138,7 @@ jobs: run: | $acsZip = Join-Path $env:RUNNER_TEMP "acs.zip" $acsDir = Join-Path $env:RUNNER_TEMP "acs" - Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/1.0.52 -OutFile $acsZip -Verbose + Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/1.0.95 -OutFile $acsZip -Verbose Expand-Archive $acsZip -Destination $acsDir -Force -Verbose # Replace ancient signtool in electron-winstall with one that supports ACS Copy-Item -Path "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\*" -Include signtool.exe,signtool.exe.manifest,Microsoft.Windows.Build.Signing.mssign32.dll.manifest,mssign32.dll,Microsoft.Windows.Build.Signing.wintrust.dll.manifest,wintrust.dll,Microsoft.Windows.Build.Appx.AppxSip.dll.manifest,AppxSip.dll,Microsoft.Windows.Build.Appx.AppxPackaging.dll.manifest,AppxPackaging.dll,Microsoft.Windows.Build.Appx.OpcServices.dll.manifest,OpcServices.dll -Destination "node_modules\electron-winstaller\vendor" -Verbose diff --git a/script/package.ts b/script/package.ts index 1b1df20ac63..48ca0744810 100644 --- a/script/package.ts +++ b/script/package.ts @@ -122,9 +122,9 @@ function packageWindows() { const metadataPath = join(acsPath, 'metadata.json') const acsMetadata = { - Endpoint: 'https://eus.codesigning.azure.net/', - CodeSigningAccountName: 'github-desktop', - CertificateProfileName: 'desktop', + Endpoint: 'https://wus.codesigning.azure.net/', + CodeSigningAccountName: 'GitHubInc', + CertificateProfileName: 'GitHubInc', CorrelationId: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`, } writeFileSync(metadataPath, JSON.stringify(acsMetadata)) From b22509c6aa8fde232293735c916beb6ed5ddc424 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Tue, 9 Dec 2025 19:05:03 +0100 Subject: [PATCH 192/865] Don't call setState after unmount --- app/src/ui/lib/sandboxed-markdown.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/ui/lib/sandboxed-markdown.tsx b/app/src/ui/lib/sandboxed-markdown.tsx index 684a7f1a2b7..c96fb13b41e 100644 --- a/app/src/ui/lib/sandboxed-markdown.tsx +++ b/app/src/ui/lib/sandboxed-markdown.tsx @@ -75,6 +75,9 @@ export class SandboxedMarkdown extends React.PureComponent< private resizeDebounceId: number | null = null private onDocumentScroll = debounce(() => { + if (this.frameRef == null) { + return + } this.setState({ tooltipOffset: this.frameRef?.getBoundingClientRect() ?? new DOMRect(), }) From c7e5bb8853e573d8e66c596b185dc8b7827effc8 Mon Sep 17 00:00:00 2001 From: Markus Olsson Date: Tue, 9 Dec 2025 19:06:26 +0100 Subject: [PATCH 193/865] Detect resize without using resize observer --- app/src/ui/lib/sandboxed-markdown.tsx | 166 +++++++++++++------------- 1 file changed, 82 insertions(+), 84 deletions(-) diff --git a/app/src/ui/lib/sandboxed-markdown.tsx b/app/src/ui/lib/sandboxed-markdown.tsx index c96fb13b41e..64853c63ebb 100644 --- a/app/src/ui/lib/sandboxed-markdown.tsx +++ b/app/src/ui/lib/sandboxed-markdown.tsx @@ -54,6 +54,10 @@ interface ISandboxedMarkdownState { readonly tooltipOffset?: DOMRect } +function once(node: T, event: string, fn: (e: Event) => void) { + node.addEventListener(event, fn, { once: true }) +} + /** * Parses and sanitizes markdown into html and outputs it inside a sandboxed * iframe. @@ -64,16 +68,8 @@ export class SandboxedMarkdown extends React.PureComponent< > { private frameRef: HTMLIFrameElement | null = null private frameContainingDivRef: HTMLDivElement | null = null - private contentDivRef: HTMLDivElement | null = null private markdownEmitter?: MarkdownEmitter - /** - * Resize observer used for tracking height changes in the markdown - * content and update the size of the iframe container. - */ - private readonly resizeObserver: ResizeObserver - private resizeDebounceId: number | null = null - private onDocumentScroll = debounce(() => { if (this.frameRef == null) { return @@ -95,31 +91,49 @@ export class SandboxedMarkdown extends React.PureComponent< { leading: true } ) + private lastContainerHeight = -Infinity + public constructor(props: ISandboxedMarkdownProps) { super(props) - this.resizeObserver = new ResizeObserver(this.scheduleResizeEvent) this.state = { tooltipElements: [] } } - private scheduleResizeEvent = () => { - if (this.resizeDebounceId !== null) { - cancelAnimationFrame(this.resizeDebounceId) - this.resizeDebounceId = null - } - this.resizeDebounceId = requestAnimationFrame(this.onContentResized) - } - - private onContentResized = () => { - if (this.frameRef === null) { + /** + * Iframes without much styling help will act like a block element that has a + * predetermiend height and width and scrolling. We want our iframe to feel a + * bit more like a div. Thus, we want to capture the scroll height, and set + * the container div to that height and with some additional css we can + * achieve a inline feel. + */ + private refreshHeight = () => { + if (this.frameRef === null || this.frameContainingDivRef === null) { return } - this.setFrameContainerHeight(this.frameRef) + const newHeight = + this.frameRef.contentDocument?.body?.firstElementChild?.clientHeight ?? + 400 + + if (newHeight !== this.lastContainerHeight) { + this.lastContainerHeight = newHeight + // Not sure why the content height != body height exactly. But we need to + // set the height explicitly to prevent scrollbar/content cut off. + // HACK: Add 1 to the new height to avoid UI glitches like the one shown + // in https://github.com/desktop/desktop/pull/18596 + this.frameContainingDivRef.style.height = `${newHeight + 1}px` + } } private onFrameRef = (frameRef: HTMLIFrameElement | null) => { this.frameRef = frameRef + this.frameRef?.addEventListener('error', e => { + console.error( + 'Error loading iframe contents. This may be due to a CSP violation.' + ) + e.preventDefault() + e.stopPropagation() + }) } private onFrameContainingDivRef = ( @@ -168,7 +182,6 @@ export class SandboxedMarkdown extends React.PureComponent< public componentWillUnmount() { this.markdownEmitter?.dispose() - this.resizeObserver.disconnect() document.removeEventListener('scroll', this.onDocumentScroll) } @@ -218,6 +231,11 @@ export class SandboxedMarkdown extends React.PureComponent< .markdown-body a { text-decoration: ${this.props.underlineLinks ? 'underline' : 'inherit'}; } + + img { + max-width: 100%; + height: auto; + } ` } @@ -226,11 +244,28 @@ export class SandboxedMarkdown extends React.PureComponent< * However, we want to intercept them an verify they are valid links first. */ private setupFrameLoadListeners(frameRef: HTMLIFrameElement): void { + const doc = frameRef.contentDocument + if (doc) { + once(doc, 'DOMContentLoaded', () => { + this.refreshHeight() + + Array.from(doc.querySelectorAll('img')).forEach(img => + once(img, 'load', this.refreshHeight) + ) + + Array.from(doc.querySelectorAll('details')).forEach(detail => + detail.addEventListener('toggle', this.refreshHeight) + ) + + this.setupLinkInterceptor(frameRef) + this.setupTooltips(frameRef) + + this.props.onMarkdownParsed?.() + }) + } + frameRef.addEventListener('load', () => { - this.setupContentDivRef(frameRef) - this.setupLinkInterceptor(frameRef) - this.setupTooltips(frameRef) - this.setFrameContainerHeight(frameRef) + this.refreshHeight() }) } @@ -255,52 +290,6 @@ export class SandboxedMarkdown extends React.PureComponent< }) } - private setupContentDivRef(frameRef: HTMLIFrameElement): void { - if (frameRef.contentDocument === null) { - return - } - - /* - * We added an additional wrapper div#content around the markdown to - * determine a more accurate scroll height as the iframe's document or body - * element was not adjusting it's height dynamically when new content was - * provided. - */ - this.contentDivRef = frameRef.contentDocument.documentElement.querySelector( - '#content' - ) as HTMLDivElement - - if (this.contentDivRef !== null) { - this.resizeObserver.disconnect() - this.resizeObserver.observe(this.contentDivRef) - } - } - - /** - * Iframes without much styling help will act like a block element that has a - * predetermiend height and width and scrolling. We want our iframe to feel a - * bit more like a div. Thus, we want to capture the scroll height, and set - * the container div to that height and with some additional css we can - * achieve a inline feel. - */ - private setFrameContainerHeight(frameRef: HTMLIFrameElement): void { - if ( - frameRef.contentDocument === null || - this.frameContainingDivRef === null || - this.contentDivRef === null - ) { - return - } - - // Not sure why the content height != body height exactly. But we need to - // set the height explicitly to prevent scrollbar/content cut off. - // HACK: Add 1 to the new height to avoid UI glitches like the one shown - // in https://github.com/desktop/desktop/pull/18596 - const divHeight = this.contentDivRef.clientHeight - this.frameContainingDivRef.style.height = `${divHeight + 1}px` - this.props.onMarkdownParsed?.() - } - /** * We still want to be able to navigate to links provided in the markdown. * However, we want to intercept them an verify they are valid links first. @@ -363,21 +352,30 @@ export class SandboxedMarkdown extends React.PureComponent< // convert non-latin strings that existed in the markedjs. const b64src = Buffer.from(src, 'utf8').toString('base64') - // HACK OR NOT? This prevents a crash since Electron 34 where the layout - // changes during the ResizeObserver callback. See: - // https://github.com/desktop/desktop/issues/20760 - requestAnimationFrame(() => { - if (this.frameRef === null) { - // If frame is destroyed before markdown parsing completes, frameref will be null. + if (this.frameRef === null) { + // If frame is destroyed before markdown parsing completes, frameref will be null. + return + } + + // We are using `src` and data uri as opposed to an html string in the + // `srcdoc` property because the `srcdoc` property renders the html in the + // parent dom and we want all rendering to be isolated to our sandboxed iframe. + // -- https://csplite.com/csp/test188/ + const oldDocument = this.frameRef.contentDocument + this.frameRef.src = `data:text/html;charset=utf-8;base64,${b64src}` + + const waitForNewDocument = () => { + if (!this.frameRef) { return } + if (this.frameRef.contentDocument === oldDocument) { + requestAnimationFrame(waitForNewDocument) + } else { + this.refreshHeight() + } + } - // We are using `src` and data uri as opposed to an html string in the - // `srcdoc` property because the `srcdoc` property renders the html in the - // parent dom and we want all rendering to be isolated to our sandboxed iframe. - // -- https://csplite.com/csp/test188/ - this.frameRef.src = `data:text/html;charset=utf-8;base64,${b64src}` - }) + requestAnimationFrame(waitForNewDocument) } public render() { @@ -391,7 +389,7 @@ export class SandboxedMarkdown extends React.PureComponent<