From 3420237c316734e8525bbbdd2bb528ec34ec7749 Mon Sep 17 00:00:00 2001 From: Voiduin Date: Sun, 29 Mar 2026 18:46:19 +0300 Subject: [PATCH 1/4] Add option to display commit author identity and git config source Show the resolved user.name and user.email above the commit summary field along with tooltips indicating which git config file each value comes from (global, includeIf, local). The feature is controlled by a toggle in Preferences > Appearance and is disabled by default. --- app/src/lib/app-state.ts | 6 ++ app/src/lib/git/config.ts | 37 +++++++++++ app/src/lib/stores/app-store.ts | 38 ++++++++++++ app/src/lib/stores/repository-state-cache.ts | 2 + app/src/ui/app.tsx | 2 + app/src/ui/changes/commit-message.tsx | 62 +++++++++++++++++-- app/src/ui/changes/filter-changes-list.tsx | 8 +++ app/src/ui/changes/sidebar.tsx | 8 +++ app/src/ui/dispatcher/dispatcher.ts | 4 ++ app/src/ui/preferences/appearance.tsx | 26 ++++++++ app/src/ui/preferences/preferences.tsx | 13 ++++ app/src/ui/repository-settings/git-config.tsx | 36 +++++++++++ .../repository-settings.tsx | 21 +++++++ app/src/ui/repository.tsx | 4 ++ app/styles/ui/changes/_commit-message.scss | 29 +++++++++ 15 files changed, 292 insertions(+), 4 deletions(-) diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 80bc0fdbfa7..36501cbe2ac 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -1,5 +1,6 @@ import { Account } from '../models/account' import { CommitIdentity } from '../models/commit-identity' +import { IConfigValueOrigin } from './git/config' import { IDiff, ImageDiffType } from '../models/diff' import { Repository, ILocalRepositoryState } from '../models/repository' import { Branch, IAheadBehind } from '../models/branch' @@ -363,6 +364,8 @@ export interface IAppState { */ readonly commitSpellcheckEnabled: boolean + readonly showCommitAuthorInfo: boolean + /** * Record of what logged in users have been checked to see if thank you is in * order for external contributions in latest release. @@ -562,6 +565,9 @@ export interface IRepositoryState { */ readonly commitAuthor: CommitIdentity | null + readonly commitAuthorNameOrigin: IConfigValueOrigin | null + readonly commitAuthorEmailOrigin: IConfigValueOrigin | null + readonly branchesState: IBranchesState readonly worktreesState: IWorktreesState diff --git a/app/src/lib/git/config.ts b/app/src/lib/git/config.ts index 08519747d03..e8a49e3e34b 100644 --- a/app/src/lib/git/config.ts +++ b/app/src/lib/git/config.ts @@ -282,3 +282,40 @@ async function removeConfigValueInPath( await git(flags, path || __dirname, 'removeConfigValueInPath', options) } + +export interface IConfigValueOrigin { + readonly value: string + readonly scope: string + readonly origin: string +} + +/** + * Look up a config value along with its source file and scope. + * Requires Git 2.26+ for --show-scope. + */ +export async function getConfigValueWithOrigin( + repository: Repository, + name: string +): Promise { + const result = await git( + ['config', '--show-origin', '--show-scope', '-z', name], + repository.path, + 'getConfigValueWithOrigin', + { successExitCodes: new Set([0, 1, 128]) } + ) + + if (result.exitCode !== 0) { + return null + } + + const parts = result.stdout.split('\0') + if (parts.length >= 3) { + return { + scope: parts[0], + origin: parts[1], + value: parts[2], + } + } + + return null +} diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 464440e8456..b675942ca89 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -301,6 +301,10 @@ import { installLFSHooks, isUsingLFS, } from '../git/lfs' +import { + getConfigValueWithOrigin, + IConfigValueOrigin, +} from '../git/config' import { determineMergeability } from '../git/merge-tree' import { listWorktrees } from '../git/worktree' import { reorder } from '../git/reorder' @@ -464,6 +468,9 @@ const hideWhitespaceInPullRequestDiffKey = const commitSpellcheckEnabledDefault = true const commitSpellcheckEnabledKey = 'commit-spellcheck-enabled' +const showCommitAuthorInfoDefault = false +const showCommitAuthorInfoKey = 'show-commit-author-info' + export const tabSizeDefault: number = 4 const tabSizeKey: string = 'tab-size' @@ -610,6 +617,7 @@ export class AppStore extends TypedBaseStore { hideWhitespaceInPullRequestDiffDefault /** Whether or not the spellchecker is enabled for commit summary and description */ private commitSpellcheckEnabled: boolean = commitSpellcheckEnabledDefault + private showCommitAuthorInfo: boolean = showCommitAuthorInfoDefault private showSideBySideDiff: boolean = ShowSideBySideDiffDefault private uncommittedChangesStrategy = defaultUncommittedChangesStrategy @@ -1224,6 +1232,7 @@ export class AppStore extends TypedBaseStore { repositoryIndicatorsEnabled: this.repositoryIndicatorsEnabled, hideWindowOnQuit: this.hideWindowOnQuit, commitSpellcheckEnabled: this.commitSpellcheckEnabled, + showCommitAuthorInfo: this.showCommitAuthorInfo, currentDragElement: this.currentDragElement, lastThankYou: this.lastThankYou, useCustomEditor: this.useCustomEditor, @@ -2590,6 +2599,10 @@ export class AppStore extends TypedBaseStore { commitSpellcheckEnabledKey, commitSpellcheckEnabledDefault ) + this.showCommitAuthorInfo = getBoolean( + showCommitAuthorInfoKey, + showCommitAuthorInfoDefault + ) this.showSideBySideDiff = getShowSideBySideDiff() this.selectedTheme = getPersistedThemeName() @@ -4225,6 +4238,17 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } + public _setShowCommitAuthorInfo(showCommitAuthorInfo: boolean) { + if (this.showCommitAuthorInfo === showCommitAuthorInfo) { + return + } + + setBoolean(showCommitAuthorInfoKey, showCommitAuthorInfo) + this.showCommitAuthorInfo = showCommitAuthorInfo + + this.emitUpdate() + } + public _setUseWindowsOpenSSH(useWindowsOpenSSH: boolean) { setBoolean(UseWindowsOpenSSHKey, useWindowsOpenSSH) this.useWindowsOpenSSH = useWindowsOpenSSH @@ -4297,8 +4321,22 @@ export class AppStore extends TypedBaseStore { getAuthorIdentity(repository) )) || null + let commitAuthorNameOrigin: IConfigValueOrigin | null = null + let commitAuthorEmailOrigin: IConfigValueOrigin | null = null + + try { + ;[commitAuthorNameOrigin, commitAuthorEmailOrigin] = await Promise.all([ + getConfigValueWithOrigin(repository, 'user.name'), + getConfigValueWithOrigin(repository, 'user.email'), + ]) + } catch (e) { + log.warn('Failed to get config value origins', e) + } + this.repositoryStateCache.update(repository, () => ({ commitAuthor, + commitAuthorNameOrigin, + commitAuthorEmailOrigin, })) this.emitUpdate() } diff --git a/app/src/lib/stores/repository-state-cache.ts b/app/src/lib/stores/repository-state-cache.ts index 51c1f02fbf4..e5c33a4b872 100644 --- a/app/src/lib/stores/repository-state-cache.ts +++ b/app/src/lib/stores/repository-state-cache.ts @@ -375,6 +375,8 @@ function getInitialRepositoryState(): IRepositoryState { }, pullRequestState: null, commitAuthor: null, + commitAuthorNameOrigin: null, + commitAuthorEmailOrigin: null, commitLookup: new Map(), localCommitSHAs: [], localTags: null, diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index d4f651c457a..32236efe1af 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -1672,6 +1672,7 @@ export class App extends React.Component { selectedExternalEditor={this.state.selectedExternalEditor} useWindowsOpenSSH={this.state.useWindowsOpenSSH} showCommitLengthWarning={this.state.showCommitLengthWarning} + showCommitAuthorInfo={this.state.showCommitAuthorInfo} notificationsEnabled={this.state.notificationsEnabled} optOutOfUsageTracking={this.state.optOutOfUsageTracking} useExternalCredentialHelper={this.state.useExternalCredentialHelper} @@ -3735,6 +3736,7 @@ export class App extends React.Component { aheadBehindStore={this.props.aheadBehindStore} commitSpellcheckEnabled={this.state.commitSpellcheckEnabled} showCommitLengthWarning={this.state.showCommitLengthWarning} + showCommitAuthorInfo={this.state.showCommitAuthorInfo} onCherryPick={this.startCherryPickWithoutBranch} pullRequestSuggestedNextAction={state.pullRequestSuggestedNextAction} showChangesFilter={state.showChangesFilter} diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index f97c64985cf..8c6d6793c89 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -37,7 +37,7 @@ import { isAttributableEmailFor, lookupPreferredEmail, } from '../../lib/email' -import { setGlobalConfigValue } from '../../lib/git/config' +import { setGlobalConfigValue, IConfigValueOrigin } from '../../lib/git/config' import { Popup, PopupType } from '../../models/popup' import { RepositorySettingsTab } from '../repository-settings/repository-settings' import { IdealSummaryLength } from '../../lib/wrap-rich-text-commit-message' @@ -70,6 +70,29 @@ import { AriaLiveContainer } from '../accessibility/aria-live-container' import { HookProgress } from '../../lib/git' import { assertNever } from '../../lib/fatal-error' +function formatConfigOriginTooltip( + fieldName: string, + configOrigin: IConfigValueOrigin +): string { + const filePath = configOrigin.origin.replace(/^file:/, '') + + let scopeDescription: string + if (configOrigin.scope === 'local') { + scopeDescription = 'local (.git/config)' + } else if (configOrigin.scope === 'system') { + scopeDescription = 'system' + } else if (configOrigin.scope === 'global') { + const isStandardGlobalPath = + /[/\\]\.gitconfig$/i.test(filePath) || + /[/\\]\.config[/\\]git[/\\]config$/i.test(filePath) + scopeDescription = isStandardGlobalPath ? 'global' : 'global (via includeIf)' + } else { + scopeDescription = configOrigin.scope + } + + return `${fieldName}: ${configOrigin.value}\nSource: ${scopeDescription}\n${filePath}` +} + const addAuthorIcon: OcticonSymbolVariant = { w: 18, h: 13, @@ -240,6 +263,10 @@ interface ICommitMessageProps { repository: Repository, options: Partial ) => void + + readonly commitAuthorNameOrigin?: IConfigValueOrigin | null + readonly commitAuthorEmailOrigin?: IConfigValueOrigin | null + readonly showCommitAuthorInfo?: boolean } interface ICommitMessageState { @@ -770,7 +797,7 @@ export class CommitMessage extends React.Component< } } - return ( + const avatar = ( ) + + if (!this.props.showCommitAuthorInfo || !commitAuthor) { + return avatar + } + + const { commitAuthorNameOrigin, commitAuthorEmailOrigin } = this.props + const nameTooltip = commitAuthorNameOrigin + ? formatConfigOriginTooltip('Name', commitAuthorNameOrigin) + : undefined + const emailTooltip = commitAuthorEmailOrigin + ? formatConfigOriginTooltip('Email', commitAuthorEmailOrigin) + : undefined + + return ( +
+ {avatar} +
+ + {commitAuthor.name} + + + {commitAuthor.email} + +
+
+ ) } private onUpdateUserEmail = async (email: string) => { @@ -1738,9 +1791,10 @@ export class CommitMessage extends React.Component< onContextMenu={this.onContextMenu} ref={this.wrapperRef} > -
- {this.renderAvatar()} + {this.props.showCommitAuthorInfo && this.renderAvatar()} +
+ {!this.props.showCommitAuthorInfo && this.renderAvatar()} /** The file list filter state containing all filter options */ @@ -1027,6 +1032,9 @@ export class FilterChangesList extends React.Component< branch={this.props.branch} mostRecentLocalCommit={this.props.mostRecentLocalCommit} commitAuthor={this.props.commitAuthor} + commitAuthorNameOrigin={this.props.commitAuthorNameOrigin} + commitAuthorEmailOrigin={this.props.commitAuthorEmailOrigin} + showCommitAuthorInfo={this.props.showCommitAuthorInfo} isShowingModal={this.props.isShowingModal} isShowingFoldout={this.props.isShowingFoldout} anyFilesSelected={anyFilesSelected} diff --git a/app/src/ui/changes/sidebar.tsx b/app/src/ui/changes/sidebar.tsx index 4a25c175ec5..ca03b488cf4 100644 --- a/app/src/ui/changes/sidebar.tsx +++ b/app/src/ui/changes/sidebar.tsx @@ -13,6 +13,7 @@ import { Repository } from '../../models/repository' import { Dispatcher } from '../dispatcher' import { IssuesStore, GitHubUserStore } from '../../lib/stores' import { CommitIdentity } from '../../models/commit-identity' +import { IConfigValueOrigin } from '../../lib/git/config' import { Commit, ICommitContext } from '../../models/commit' import { UndoCommit } from './undo-commit' import { @@ -48,6 +49,8 @@ interface IChangesSidebarProps { readonly aheadBehind: IAheadBehind | null readonly dispatcher: Dispatcher readonly commitAuthor: CommitIdentity | null + readonly commitAuthorNameOrigin?: IConfigValueOrigin | null + readonly commitAuthorEmailOrigin?: IConfigValueOrigin | null readonly branch: string | null readonly emoji: Map readonly mostRecentLocalCommit: Commit | null @@ -94,6 +97,8 @@ interface IChangesSidebarProps { readonly showCommitLengthWarning: boolean + readonly showCommitAuthorInfo: boolean + /** Whether or not to show the changes filter */ readonly showChangesFilter: boolean @@ -460,6 +465,9 @@ export class ChangesSidebar extends React.Component { onOpenItem={this.onOpenItem} onRowClick={this.onChangedItemClick} commitAuthor={this.props.commitAuthor} + commitAuthorNameOrigin={this.props.commitAuthorNameOrigin} + commitAuthorEmailOrigin={this.props.commitAuthorEmailOrigin} + showCommitAuthorInfo={this.props.showCommitAuthorInfo} branch={this.props.branch} commitMessage={commitMessage} focusCommitMessage={this.props.focusCommitMessage} diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index f970c1c1ffa..c877b5cd98b 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -2944,6 +2944,10 @@ export class Dispatcher { this.appStore._setCommitSpellcheckEnabled(commitSpellcheckEnabled) } + public setShowCommitAuthorInfo(showCommitAuthorInfo: boolean) { + this.appStore._setShowCommitAuthorInfo(showCommitAuthorInfo) + } + public setUseWindowsOpenSSH(useWindowsOpenSSH: boolean) { this.appStore._setUseWindowsOpenSSH(useWindowsOpenSSH) } diff --git a/app/src/ui/preferences/appearance.tsx b/app/src/ui/preferences/appearance.tsx index 77cd0d1089d..3e8e61a7b09 100644 --- a/app/src/ui/preferences/appearance.tsx +++ b/app/src/ui/preferences/appearance.tsx @@ -39,6 +39,8 @@ interface IAppearanceProps { readonly onBranchSortOrderChanged: (sortOrder: BranchSortOrder) => void readonly commitDateDisplay: CommitDateDisplay readonly onCommitDateDisplayChanged: (value: CommitDateDisplay) => void + readonly showCommitAuthorInfo: boolean + readonly onShowCommitAuthorInfoChanged: (show: boolean) => void } interface IAppearanceState { @@ -411,7 +413,31 @@ export class Appearance extends React.Component< {this.renderWorktreeVisibility()} {this.renderSelectedTabSize()} {this.renderTitleBarStyleDropdown()} + {this.renderCommitAuthorInfo()} ) } + + private onShowCommitAuthorInfoChanged = ( + event: React.FormEvent + ) => { + this.props.onShowCommitAuthorInfoChanged(event.currentTarget.checked) + } + + private renderCommitAuthorInfo() { + return ( +
+

Commit Author

+ +
+ ) + } } diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index a2368e5dc9e..adfae63df84 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -73,6 +73,7 @@ interface IPreferencesProps { readonly onDismissed: () => void readonly useWindowsOpenSSH: boolean readonly showCommitLengthWarning: boolean + readonly showCommitAuthorInfo: boolean readonly notificationsEnabled: boolean readonly optOutOfUsageTracking: boolean readonly useExternalCredentialHelper: boolean @@ -122,6 +123,7 @@ interface IPreferencesState { readonly disallowedCharactersMessage: string | null readonly useWindowsOpenSSH: boolean readonly showCommitLengthWarning: boolean + readonly showCommitAuthorInfo: boolean readonly notificationsEnabled: boolean readonly optOutOfUsageTracking: boolean readonly useExternalCredentialHelper: boolean @@ -216,6 +218,7 @@ export class Preferences extends React.Component< this.props.branchPresetScript ?? DefaultCustomIntegration, useWindowsOpenSSH: false, showCommitLengthWarning: false, + showCommitAuthorInfo: false, notificationsEnabled: true, optOutOfUsageTracking: false, useExternalCredentialHelper: false, @@ -298,6 +301,7 @@ export class Preferences extends React.Component< initialDefaultBranch, useWindowsOpenSSH: this.props.useWindowsOpenSSH, showCommitLengthWarning: this.props.showCommitLengthWarning, + showCommitAuthorInfo: this.props.showCommitAuthorInfo, notificationsEnabled: this.props.notificationsEnabled, optOutOfUsageTracking: this.props.optOutOfUsageTracking, useExternalCredentialHelper: this.props.useExternalCredentialHelper, @@ -587,6 +591,8 @@ export class Preferences extends React.Component< onBranchSortOrderChanged={this.onBranchSortOrderChanged} commitDateDisplay={this.state.commitDateDisplay} onCommitDateDisplayChanged={this.onCommitDateDisplayChanged} + showCommitAuthorInfo={this.state.showCommitAuthorInfo} + onShowCommitAuthorInfoChanged={this.onShowCommitAuthorInfoChanged} /> ) break @@ -719,6 +725,12 @@ export class Preferences extends React.Component< this.setState({ showCommitLengthWarning }) } + private onShowCommitAuthorInfoChanged = ( + showCommitAuthorInfo: boolean + ) => { + this.setState({ showCommitAuthorInfo }) + } + private onNotificationsEnabledChanged = (notificationsEnabled: boolean) => { this.setState({ notificationsEnabled }) } @@ -983,6 +995,7 @@ export class Preferences extends React.Component< dispatcher.setUseWindowsOpenSSH(this.state.useWindowsOpenSSH) dispatcher.setShowCommitLengthWarning(this.state.showCommitLengthWarning) + dispatcher.setShowCommitAuthorInfo(this.state.showCommitAuthorInfo) dispatcher.setNotificationsEnabled(this.state.notificationsEnabled) await dispatcher.setStatsOptOut(this.state.optOutOfUsageTracking, false) diff --git a/app/src/ui/repository-settings/git-config.tsx b/app/src/ui/repository-settings/git-config.tsx index 0bfff798edc..736f785c907 100644 --- a/app/src/ui/repository-settings/git-config.tsx +++ b/app/src/ui/repository-settings/git-config.tsx @@ -5,6 +5,7 @@ import { GitConfigUserForm } from '../lib/git-config-user-form' import { Row } from '../lib/row' import { RadioGroup } from '../lib/radio-group' import { assertNever } from '../../lib/fatal-error' +import { IConfigValueOrigin } from '../../lib/git/config' import memoizeOne from 'memoize-one' interface IGitConfigProps { @@ -17,6 +18,9 @@ interface IGitConfigProps { readonly globalEmail: string readonly isLoadingGitConfig: boolean + readonly nameOrigin?: IConfigValueOrigin | null + readonly emailOrigin?: IConfigValueOrigin | null + readonly onGitConfigLocationChanged: (value: GitConfigLocation) => void readonly onNameChanged: (name: string) => void readonly onEmailChanged: (email: string) => void @@ -84,8 +88,40 @@ export class GitConfig extends React.Component { onNameChanged={this.props.onNameChanged} isLoadingGitConfig={this.props.isLoadingGitConfig} /> + {this.renderConfigOrigin()}
) } + + private renderConfigOrigin() { + const { nameOrigin, emailOrigin } = this.props + if (!nameOrigin && !emailOrigin) { + return null + } + + return ( +
+

+ Effective identity resolved from git config: +

+ {nameOrigin && ( + + + user.name = "{nameOrigin.value}" ({nameOrigin.scope}{' '} + — {nameOrigin.origin.replace(/^file:/, '')}) + + + )} + {emailOrigin && ( + + + user.email = "{emailOrigin.value}" ({emailOrigin.scope}{' '} + — {emailOrigin.origin.replace(/^file:/, '')}) + + + )} +
+ ) + } } diff --git a/app/src/ui/repository-settings/repository-settings.tsx b/app/src/ui/repository-settings/repository-settings.tsx index 18277b2ce4b..2403cc9c456 100644 --- a/app/src/ui/repository-settings/repository-settings.tsx +++ b/app/src/ui/repository-settings/repository-settings.tsx @@ -21,8 +21,10 @@ import { GitConfigLocation, GitConfig } from './git-config' import { getConfigValue, getGlobalConfigValue, + getConfigValueWithOrigin, removeConfigValue, setConfigValue, + IConfigValueOrigin, } from '../../lib/git/config' import { gitAuthorNameIsValid, @@ -72,6 +74,8 @@ interface IRepositorySettingsState { readonly errors?: ReadonlyArray readonly forkContributionTarget: ForkContributionTarget readonly isLoadingGitConfig: boolean + readonly nameOrigin: IConfigValueOrigin | null + readonly emailOrigin: IConfigValueOrigin | null readonly availableEditors: ReadonlyArray readonly useDefaultEditor: boolean readonly selectedExternalEditor: string | null @@ -106,6 +110,8 @@ export class RepositorySettings extends React.Component< initialCommitterName: null, initialCommitterEmail: null, isLoadingGitConfig: true, + nameOrigin: null, + emailOrigin: null, availableEditors: [], useDefaultEditor: !props.repository.customEditorOverride, selectedExternalEditor: @@ -163,6 +169,17 @@ export class RepositorySettings extends React.Component< committerEmail = localCommitterEmail ?? '' } + let nameOrigin: IConfigValueOrigin | null = null + let emailOrigin: IConfigValueOrigin | null = null + try { + ;[nameOrigin, emailOrigin] = await Promise.all([ + getConfigValueWithOrigin(this.props.repository, 'user.name'), + getConfigValueWithOrigin(this.props.repository, 'user.email'), + ]) + } catch (e) { + log.warn('Failed to get config value origins', e) + } + this.setState({ gitConfigLocation, committerName, @@ -174,6 +191,8 @@ export class RepositorySettings extends React.Component< initialCommitterName: localCommitterName, initialCommitterEmail: localCommitterEmail, isLoadingGitConfig: false, + nameOrigin, + emailOrigin, }) } @@ -307,6 +326,8 @@ export class RepositorySettings extends React.Component< onNameChanged={this.onCommitterNameChanged} onEmailChanged={this.onCommitterEmailChanged} isLoadingGitConfig={this.state.isLoadingGitConfig} + nameOrigin={this.state.nameOrigin} + emailOrigin={this.state.emailOrigin} /> ) } diff --git a/app/src/ui/repository.tsx b/app/src/ui/repository.tsx index 8c7906b2c95..9fd3392e424 100644 --- a/app/src/ui/repository.tsx +++ b/app/src/ui/repository.tsx @@ -64,6 +64,7 @@ interface IRepositoryViewProps { readonly focusCommitMessage: boolean readonly commitSpellcheckEnabled: boolean readonly showCommitLengthWarning: boolean + readonly showCommitAuthorInfo: boolean readonly accounts: ReadonlyArray readonly shouldShowGenerateCommitMessageCallOut: boolean @@ -347,6 +348,8 @@ export class RepositoryView extends React.Component< aheadBehind={this.props.state.aheadBehind} branch={branchName} commitAuthor={this.props.state.commitAuthor} + commitAuthorNameOrigin={this.props.state.commitAuthorNameOrigin} + commitAuthorEmailOrigin={this.props.state.commitAuthorEmailOrigin} emoji={this.props.emoji} mostRecentLocalCommit={mostRecentLocalCommit} issuesStore={this.props.issuesStore} @@ -387,6 +390,7 @@ export class RepositoryView extends React.Component< } commitSpellcheckEnabled={this.props.commitSpellcheckEnabled} showCommitLengthWarning={this.props.showCommitLengthWarning} + showCommitAuthorInfo={this.props.showCommitAuthorInfo} showChangesFilter={this.props.showChangesFilter} hasCommitHooks={this.props.hasCommitHooks} skipCommitHooks={this.props.skipCommitHooks} diff --git a/app/styles/ui/changes/_commit-message.scss b/app/styles/ui/changes/_commit-message.scss index c0edd4d2930..16e32f0106b 100644 --- a/app/styles/ui/changes/_commit-message.scss +++ b/app/styles/ui/changes/_commit-message.scss @@ -80,6 +80,35 @@ } } + .commit-author-identity { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-half); + margin-bottom: var(--spacing-half); + + .commit-author-info { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.3; + + .commit-author-name { + font-size: var(--font-size-sm); + font-weight: 600; + @include ellipsis; + cursor: default; + } + + .commit-author-email { + font-size: var(--font-size-xs); + color: var(--text-secondary-color); + @include ellipsis; + cursor: default; + } + } + } + .summary { position: relative; display: flex; From afd1053ba806b9fc8fe47866cbd7a2e18b23ff04 Mon Sep 17 00:00:00 2001 From: Voiduin Date: Mon, 30 Mar 2026 16:52:35 +0300 Subject: [PATCH 2/4] Fix for CI-CD linter and change view tooltip with config --- app/src/lib/git/config.ts | 41 ++++++++++++ app/src/lib/stores/app-store.ts | 5 +- app/src/ui/changes/commit-message.tsx | 63 +++++++++++-------- app/src/ui/preferences/preferences.tsx | 4 +- app/src/ui/repository-settings/git-config.tsx | 50 ++++++++------- .../repository-settings.tsx | 1 + .../ui/dialogs/_repository-settings.scss | 23 +++++++ app/styles/ui/window/_tooltips.scss | 13 ++++ 8 files changed, 145 insertions(+), 55 deletions(-) diff --git a/app/src/lib/git/config.ts b/app/src/lib/git/config.ts index e8a49e3e34b..ede30f54a13 100644 --- a/app/src/lib/git/config.ts +++ b/app/src/lib/git/config.ts @@ -301,6 +301,7 @@ export async function getConfigValueWithOrigin( ['config', '--show-origin', '--show-scope', '-z', name], repository.path, 'getConfigValueWithOrigin', + // 0 = found, 1 = key not set, 128 = not a git repo or git error { successExitCodes: new Set([0, 1, 128]) } ) @@ -319,3 +320,43 @@ export async function getConfigValueWithOrigin( return null } + +/** + * Format a human-readable scope description for a config value origin. + * Detects whether a global-scoped value comes from a standard location + * (~/.gitconfig or ~/.config/git/config) vs. a conditionally included file + * (via includeIf directive). + */ +export function formatConfigScope(origin: IConfigValueOrigin): string { + const filePath = origin.origin.replace(/^file:/, '') + if (origin.scope === 'local') { + return 'local' + } else if (origin.scope === 'system') { + return 'system' + } else if (origin.scope === 'global') { + const isStandardGlobalPath = + /[/\\]\.gitconfig$/i.test(filePath) || + /[/\\]\.config[/\\]git[/\\]config$/i.test(filePath) + return isStandardGlobalPath ? 'global' : 'global (via includeIf)' + } + return origin.scope +} + +/** + * Format the file path for a config value origin. + * For local scope, replaces the repository path prefix with ``. + */ +export function formatConfigPath( + origin: IConfigValueOrigin, + repositoryPath: string +): string { + const filePath = origin.origin.replace(/^file:/, '') + const normalized = repositoryPath.replace(/[\\/]+$/, '') + if ( + origin.scope === 'local' && + filePath.toLowerCase().startsWith(normalized.toLowerCase()) + ) { + return '' + filePath.slice(normalized.length) + } + return filePath +} diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index b675942ca89..d16753631d8 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -301,10 +301,7 @@ import { installLFSHooks, isUsingLFS, } from '../git/lfs' -import { - getConfigValueWithOrigin, - IConfigValueOrigin, -} from '../git/config' +import { getConfigValueWithOrigin, IConfigValueOrigin } from '../git/config' import { determineMergeability } from '../git/merge-tree' import { listWorktrees } from '../git/worktree' import { reorder } from '../git/reorder' diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index 8c6d6793c89..9897628bf56 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -37,7 +37,12 @@ import { isAttributableEmailFor, lookupPreferredEmail, } from '../../lib/email' -import { setGlobalConfigValue, IConfigValueOrigin } from '../../lib/git/config' +import { + setGlobalConfigValue, + IConfigValueOrigin, + formatConfigScope, + formatConfigPath, +} from '../../lib/git/config' import { Popup, PopupType } from '../../models/popup' import { RepositorySettingsTab } from '../repository-settings/repository-settings' import { IdealSummaryLength } from '../../lib/wrap-rich-text-commit-message' @@ -67,30 +72,25 @@ import { enableHooksEnvironment, } from '../../lib/feature-flag' import { AriaLiveContainer } from '../accessibility/aria-live-container' +import { TooltippedContent } from '../lib/tooltipped-content' import { HookProgress } from '../../lib/git' import { assertNever } from '../../lib/fatal-error' function formatConfigOriginTooltip( fieldName: string, - configOrigin: IConfigValueOrigin -): string { - const filePath = configOrigin.origin.replace(/^file:/, '') - - let scopeDescription: string - if (configOrigin.scope === 'local') { - scopeDescription = 'local (.git/config)' - } else if (configOrigin.scope === 'system') { - scopeDescription = 'system' - } else if (configOrigin.scope === 'global') { - const isStandardGlobalPath = - /[/\\]\.gitconfig$/i.test(filePath) || - /[/\\]\.config[/\\]git[/\\]config$/i.test(filePath) - scopeDescription = isStandardGlobalPath ? 'global' : 'global (via includeIf)' - } else { - scopeDescription = configOrigin.scope - } - - return `${fieldName}: ${configOrigin.value}\nSource: ${scopeDescription}\n${filePath}` + origin: IConfigValueOrigin, + repositoryPath: string +): JSX.Element { + return ( +
+ {fieldName}: + {origin.value} + Source: + {formatConfigScope(origin)} + File: + {formatConfigPath(origin, repositoryPath)} +
+ ) } const addAuthorIcon: OcticonSymbolVariant = { @@ -824,23 +824,32 @@ export class CommitMessage extends React.Component< } const { commitAuthorNameOrigin, commitAuthorEmailOrigin } = this.props + const repoPath = this.props.repository.path const nameTooltip = commitAuthorNameOrigin - ? formatConfigOriginTooltip('Name', commitAuthorNameOrigin) + ? formatConfigOriginTooltip('Name', commitAuthorNameOrigin, repoPath) : undefined const emailTooltip = commitAuthorEmailOrigin - ? formatConfigOriginTooltip('Email', commitAuthorEmailOrigin) + ? formatConfigOriginTooltip('Email', commitAuthorEmailOrigin, repoPath) : undefined return (
{avatar}
- + {commitAuthor.name} - - + + {commitAuthor.email} - +
) @@ -1791,6 +1800,8 @@ export class CommitMessage extends React.Component< onContextMenu={this.onContextMenu} ref={this.wrapperRef} > + {/* When showing author info, avatar is rendered above the summary + row as part of the identity block. Otherwise, inline in summary. */} {this.props.showCommitAuthorInfo && this.renderAvatar()}
diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index adfae63df84..16646d7fdc4 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -725,9 +725,7 @@ export class Preferences extends React.Component< this.setState({ showCommitLengthWarning }) } - private onShowCommitAuthorInfoChanged = ( - showCommitAuthorInfo: boolean - ) => { + private onShowCommitAuthorInfoChanged = (showCommitAuthorInfo: boolean) => { this.setState({ showCommitAuthorInfo }) } diff --git a/app/src/ui/repository-settings/git-config.tsx b/app/src/ui/repository-settings/git-config.tsx index 736f785c907..de43c9c0a67 100644 --- a/app/src/ui/repository-settings/git-config.tsx +++ b/app/src/ui/repository-settings/git-config.tsx @@ -5,7 +5,11 @@ import { GitConfigUserForm } from '../lib/git-config-user-form' import { Row } from '../lib/row' import { RadioGroup } from '../lib/radio-group' import { assertNever } from '../../lib/fatal-error' -import { IConfigValueOrigin } from '../../lib/git/config' +import { + IConfigValueOrigin, + formatConfigScope, + formatConfigPath, +} from '../../lib/git/config' import memoizeOne from 'memoize-one' interface IGitConfigProps { @@ -20,6 +24,7 @@ interface IGitConfigProps { readonly nameOrigin?: IConfigValueOrigin | null readonly emailOrigin?: IConfigValueOrigin | null + readonly repositoryPath: string readonly onGitConfigLocationChanged: (value: GitConfigLocation) => void readonly onNameChanged: (name: string) => void @@ -88,12 +93,29 @@ export class GitConfig extends React.Component { onNameChanged={this.props.onNameChanged} isLoadingGitConfig={this.props.isLoadingGitConfig} /> - {this.renderConfigOrigin()}
+ {this.renderConfigOrigin()} ) } + private renderOriginEntry(key: string, origin: IConfigValueOrigin) { + const repoPath = this.props.repositoryPath + return ( +
+
+ {key} = {origin.value} +
+
+ Scope: {formatConfigScope(origin)} +
+
+ File: {formatConfigPath(origin, repoPath)} +
+
+ ) + } + private renderConfigOrigin() { const { nameOrigin, emailOrigin } = this.props if (!nameOrigin && !emailOrigin) { @@ -101,26 +123,10 @@ export class GitConfig extends React.Component { } return ( -
-

- Effective identity resolved from git config: -

- {nameOrigin && ( - - - user.name = "{nameOrigin.value}" ({nameOrigin.scope}{' '} - — {nameOrigin.origin.replace(/^file:/, '')}) - - - )} - {emailOrigin && ( - - - user.email = "{emailOrigin.value}" ({emailOrigin.scope}{' '} - — {emailOrigin.origin.replace(/^file:/, '')}) - - - )} +
+

Resolved effective identity

+ {nameOrigin && this.renderOriginEntry('user.name', nameOrigin)} + {emailOrigin && this.renderOriginEntry('user.email', emailOrigin)}
) } diff --git a/app/src/ui/repository-settings/repository-settings.tsx b/app/src/ui/repository-settings/repository-settings.tsx index 2403cc9c456..bc1f654fd3f 100644 --- a/app/src/ui/repository-settings/repository-settings.tsx +++ b/app/src/ui/repository-settings/repository-settings.tsx @@ -328,6 +328,7 @@ export class RepositorySettings extends React.Component< isLoadingGitConfig={this.state.isLoadingGitConfig} nameOrigin={this.state.nameOrigin} emailOrigin={this.state.emailOrigin} + repositoryPath={this.props.repository.path} /> ) } diff --git a/app/styles/ui/dialogs/_repository-settings.scss b/app/styles/ui/dialogs/_repository-settings.scss index b1552ecf49c..aa06585ba99 100644 --- a/app/styles/ui/dialogs/_repository-settings.scss +++ b/app/styles/ui/dialogs/_repository-settings.scss @@ -57,4 +57,27 @@ padding-left: 0; padding-right: 0; } + + .git-config-origin-hint { + margin-top: var(--spacing); + color: var(--text-secondary-color); + + .git-config-origin-card { + margin-top: var(--spacing-half); + padding: var(--spacing-half) var(--spacing); + background-color: var(--box-alt-background-color); + border-radius: var(--border-radius); + + .git-config-origin-key { + font-family: var(--font-family-monospace); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + } + + .git-config-origin-detail { + font-size: var(--font-size-xs); + word-break: break-all; + } + } + } } diff --git a/app/styles/ui/window/_tooltips.scss b/app/styles/ui/window/_tooltips.scss index 3b8e5bc664f..9c2cffb72c4 100644 --- a/app/styles/ui/window/_tooltips.scss +++ b/app/styles/ui/window/_tooltips.scss @@ -212,6 +212,19 @@ body > .tooltip, } } + &.config-origin { + .config-origin-tooltip { + display: grid; + grid-template-columns: auto 1fr; + column-gap: var(--spacing-half); + + .config-origin-tooltip-label { + font-weight: bold; + text-align: right; + } + } + } + &.window-controls-tooltip { background-color: var(--toolbar-tooltip-background-color); box-shadow: 0 8px 24px var(--toolbar-tooltip-shadow-color); From e0ac10f8ee2ee43eac0b9bd21110009cd9f4f310 Mon Sep 17 00:00:00 2001 From: Voiduin Date: Mon, 30 Mar 2026 22:58:27 +0300 Subject: [PATCH 3/4] Replace checkbox to Git section and fix style in repo settings --- app/src/lib/git/config.ts | 46 ++++++++++++---- app/src/ui/changes/commit-message.tsx | 16 +++++- app/src/ui/preferences/appearance.tsx | 26 --------- app/src/ui/preferences/git.tsx | 54 +++++++++++++++++++ app/src/ui/preferences/preferences.tsx | 40 +++++++++++--- app/src/ui/repository-settings/git-config.tsx | 54 +++++++++++++++---- .../ui/dialogs/_repository-settings.scss | 9 ++-- 7 files changed, 184 insertions(+), 61 deletions(-) diff --git a/app/src/lib/git/config.ts b/app/src/lib/git/config.ts index ede30f54a13..f98322b8063 100644 --- a/app/src/lib/git/config.ts +++ b/app/src/lib/git/config.ts @@ -321,6 +321,24 @@ export async function getConfigValueWithOrigin( return null } +/** + * Extract the file path from a config value origin, stripping the `file:` prefix. + * When repositoryPath is provided, relative paths (e.g. `.git/config` for local + * scope) are resolved to absolute paths. + */ +export function getOriginFilePath( + origin: IConfigValueOrigin, + repositoryPath?: string +): string { + const filePath = origin.origin.replace(/^file:/, '') + // Git returns relative paths for local/worktree scope (e.g. `.git/config`) + if (repositoryPath && !/^([a-zA-Z]:|[/\\])/.test(filePath)) { + const base = repositoryPath.replace(/[\\/]+$/, '') + return `${base}/${filePath}` + } + return filePath +} + /** * Format a human-readable scope description for a config value origin. * Detects whether a global-scoped value comes from a standard location @@ -328,35 +346,41 @@ export async function getConfigValueWithOrigin( * (via includeIf directive). */ export function formatConfigScope(origin: IConfigValueOrigin): string { - const filePath = origin.origin.replace(/^file:/, '') + const filePath = getOriginFilePath(origin) if (origin.scope === 'local') { return 'local' } else if (origin.scope === 'system') { return 'system' + } else if (origin.scope === 'worktree') { + return 'worktree' } else if (origin.scope === 'global') { const isStandardGlobalPath = /[/\\]\.gitconfig$/i.test(filePath) || /[/\\]\.config[/\\]git[/\\]config$/i.test(filePath) - return isStandardGlobalPath ? 'global' : 'global (via includeIf)' + return isStandardGlobalPath ? 'global' : 'global, via [includeIf]' } return origin.scope } /** * Format the file path for a config value origin. - * For local scope, replaces the repository path prefix with ``. + * For local/worktree scope, displays the path with a `` prefix. */ export function formatConfigPath( origin: IConfigValueOrigin, repositoryPath: string ): string { - const filePath = origin.origin.replace(/^file:/, '') - const normalized = repositoryPath.replace(/[\\/]+$/, '') - if ( - origin.scope === 'local' && - filePath.toLowerCase().startsWith(normalized.toLowerCase()) - ) { - return '' + filePath.slice(normalized.length) + const rawPath = origin.origin.replace(/^file:/, '') + if (origin.scope === 'local' || origin.scope === 'worktree') { + // Git returns relative paths for local scope (e.g. `.git/config`) + if (!/^([a-zA-Z]:|[/\\])/.test(rawPath)) { + return '/' + rawPath + } + // Absolute path — strip repo prefix + const normalized = repositoryPath.replace(/[\\/]+$/, '') + if (rawPath.toLowerCase().startsWith(normalized.toLowerCase())) { + return '' + rawPath.slice(normalized.length) + } } - return filePath + return rawPath } diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index 9897628bf56..183dd914328 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -76,6 +76,18 @@ import { TooltippedContent } from '../lib/tooltipped-content' import { HookProgress } from '../../lib/git' import { assertNever } from '../../lib/fatal-error' +function renderScopeValue(origin: IConfigValueOrigin): JSX.Element { + const scope = formatConfigScope(origin) + if (scope.includes('includeIf')) { + return ( + + global, via [includeIf] + + ) + } + return {scope} +} + function formatConfigOriginTooltip( fieldName: string, origin: IConfigValueOrigin, @@ -85,8 +97,8 @@ function formatConfigOriginTooltip(
{fieldName}: {origin.value} - Source: - {formatConfigScope(origin)} + Scope: + {renderScopeValue(origin)} File: {formatConfigPath(origin, repositoryPath)}
diff --git a/app/src/ui/preferences/appearance.tsx b/app/src/ui/preferences/appearance.tsx index 3e8e61a7b09..77cd0d1089d 100644 --- a/app/src/ui/preferences/appearance.tsx +++ b/app/src/ui/preferences/appearance.tsx @@ -39,8 +39,6 @@ interface IAppearanceProps { readonly onBranchSortOrderChanged: (sortOrder: BranchSortOrder) => void readonly commitDateDisplay: CommitDateDisplay readonly onCommitDateDisplayChanged: (value: CommitDateDisplay) => void - readonly showCommitAuthorInfo: boolean - readonly onShowCommitAuthorInfoChanged: (show: boolean) => void } interface IAppearanceState { @@ -413,31 +411,7 @@ export class Appearance extends React.Component< {this.renderWorktreeVisibility()} {this.renderSelectedTabSize()} {this.renderTitleBarStyleDropdown()} - {this.renderCommitAuthorInfo()} ) } - - private onShowCommitAuthorInfoChanged = ( - event: React.FormEvent - ) => { - this.props.onShowCommitAuthorInfoChanged(event.currentTarget.checked) - } - - private renderCommitAuthorInfo() { - return ( -
-

Commit Author

- -
- ) - } } diff --git a/app/src/ui/preferences/git.tsx b/app/src/ui/preferences/git.tsx index 964cfab5bb7..e2b71791dc5 100644 --- a/app/src/ui/preferences/git.tsx +++ b/app/src/ui/preferences/git.tsx @@ -37,6 +37,13 @@ interface IGitProps { readonly enableGitHookEnv: boolean readonly cacheGitHookEnv: boolean readonly selectedShell: string + + readonly showCommitAuthorInfo: boolean + readonly onShowCommitAuthorInfoChanged: (show: boolean) => void + + readonly setGlobalAuthor: boolean + readonly globalAuthorWasSet: boolean + readonly onSetGlobalAuthorChanged: (value: boolean) => void } const windowsShells: ReadonlyArray = [ @@ -172,9 +179,37 @@ export class Git extends React.Component { return null } + private onSetGlobalAuthorChanged = ( + event: React.FormEvent + ) => { + this.props.onSetGlobalAuthorChanged(event.currentTarget.checked) + } + + private onShowCommitAuthorInfoChanged = ( + event: React.FormEvent + ) => { + this.props.onShowCommitAuthorInfoChanged(event.currentTarget.checked) + } + private renderGitConfigAuthorInfo() { return ( <> +

Global Author

+ + {!this.props.setGlobalAuthor && this.props.globalAuthorWasSet && ( +
+ ⚠️ + Saving will remove user.name and user.email from your global Git + config. Make sure your repositories have local config or includeIf + rules set up, otherwise commits may fail. +
+ )} { accounts={this.props.accounts} onEmailChanged={this.props.onEmailChanged} onNameChanged={this.props.onNameChanged} + disabled={!this.props.setGlobalAuthor} /> {this.renderEditGlobalGitConfigInfo()} +

Commit Identity Display

+ +

+ Git resolves author identity from multiple config files with different + priorities.{' '} + + Learn more about config scopes + + . +

) } diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index 16646d7fdc4..3a0fcf522bf 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -11,6 +11,7 @@ import { Dialog, DialogFooter, DialogError } from '../dialog' import { getGlobalConfigValue, setGlobalConfigValue, + removeGlobalConfigValue, } from '../../lib/git/config' import { lookupPreferredEmail } from '../../lib/email' import { Shell, getAvailableShells } from '../../lib/shells' @@ -120,6 +121,7 @@ interface IPreferencesState { readonly initialCommitterName: string | null readonly initialCommitterEmail: string | null readonly initialDefaultBranch: string | null + readonly setGlobalAuthor: boolean readonly disallowedCharactersMessage: string | null readonly useWindowsOpenSSH: boolean readonly showCommitLengthWarning: boolean @@ -208,6 +210,7 @@ export class Preferences extends React.Component< initialCommitterName: null, initialCommitterEmail: null, initialDefaultBranch: null, + setGlobalAuthor: false, disallowedCharactersMessage: null, availableEditors: [], useCustomEditor: this.props.useCustomEditor, @@ -299,6 +302,7 @@ export class Preferences extends React.Component< initialCommitterName, initialCommitterEmail, initialDefaultBranch, + setGlobalAuthor: !!initialCommitterName || !!initialCommitterEmail, useWindowsOpenSSH: this.props.useWindowsOpenSSH, showCommitLengthWarning: this.props.showCommitLengthWarning, showCommitAuthorInfo: this.props.showCommitAuthorInfo, @@ -561,6 +565,14 @@ export class Preferences extends React.Component< selectedShell={ this.state.selectedGitHookEnvShell ?? defaultGitHookEnvShell } + showCommitAuthorInfo={this.state.showCommitAuthorInfo} + onShowCommitAuthorInfoChanged={this.onShowCommitAuthorInfoChanged} + setGlobalAuthor={this.state.setGlobalAuthor} + globalAuthorWasSet={ + !!this.state.initialCommitterName || + !!this.state.initialCommitterEmail + } + onSetGlobalAuthorChanged={this.onSetGlobalAuthorChanged} /> ) @@ -591,8 +603,6 @@ export class Preferences extends React.Component< onBranchSortOrderChanged={this.onBranchSortOrderChanged} commitDateDisplay={this.state.commitDateDisplay} onCommitDateDisplayChanged={this.onCommitDateDisplayChanged} - showCommitAuthorInfo={this.state.showCommitAuthorInfo} - onShowCommitAuthorInfoChanged={this.onShowCommitAuthorInfoChanged} /> ) break @@ -729,6 +739,10 @@ export class Preferences extends React.Component< this.setState({ showCommitAuthorInfo }) } + private onSetGlobalAuthorChanged = (setGlobalAuthor: boolean) => { + this.setState({ setGlobalAuthor }) + } + private onNotificationsEnabledChanged = (notificationsEnabled: boolean) => { this.setState({ notificationsEnabled }) } @@ -905,13 +919,23 @@ export class Preferences extends React.Component< try { let shouldRefreshAuthor = false - if (this.state.committerName !== this.state.initialCommitterName) { - await setGlobalConfigValue('user.name', this.state.committerName) - shouldRefreshAuthor = true - } + if (this.state.setGlobalAuthor) { + if (this.state.committerName !== this.state.initialCommitterName) { + await setGlobalConfigValue('user.name', this.state.committerName) + shouldRefreshAuthor = true + } - if (this.state.committerEmail !== this.state.initialCommitterEmail) { - await setGlobalConfigValue('user.email', this.state.committerEmail) + if (this.state.committerEmail !== this.state.initialCommitterEmail) { + await setGlobalConfigValue('user.email', this.state.committerEmail) + shouldRefreshAuthor = true + } + } else if ( + this.state.initialCommitterName || + this.state.initialCommitterEmail + ) { + // User unchecked the box — remove identity from global config + await removeGlobalConfigValue('user.name') + await removeGlobalConfigValue('user.email') shouldRefreshAuthor = true } diff --git a/app/src/ui/repository-settings/git-config.tsx b/app/src/ui/repository-settings/git-config.tsx index de43c9c0a67..585ab0d1440 100644 --- a/app/src/ui/repository-settings/git-config.tsx +++ b/app/src/ui/repository-settings/git-config.tsx @@ -4,12 +4,15 @@ import { Account } from '../../models/account' import { GitConfigUserForm } from '../lib/git-config-user-form' import { Row } from '../lib/row' import { RadioGroup } from '../lib/radio-group' +import { LinkButton } from '../lib/link-button' import { assertNever } from '../../lib/fatal-error' import { IConfigValueOrigin, + getOriginFilePath, formatConfigScope, formatConfigPath, } from '../../lib/git/config' +import { showItemInFolder } from '../main-process-proxy' import memoizeOne from 'memoize-one' interface IGitConfigProps { @@ -99,18 +102,41 @@ export class GitConfig extends React.Component { ) } - private renderOriginEntry(key: string, origin: IConfigValueOrigin) { + private onRevealNameConfigFile = () => { + if (this.props.nameOrigin) { + showItemInFolder( + getOriginFilePath(this.props.nameOrigin, this.props.repositoryPath) + ) + } + } + + private onRevealEmailConfigFile = () => { + if (this.props.emailOrigin) { + showItemInFolder( + getOriginFilePath(this.props.emailOrigin, this.props.repositoryPath) + ) + } + } + + private renderOriginEntry( + key: string, + origin: IConfigValueOrigin, + onReveal: () => void + ) { const repoPath = this.props.repositoryPath return ( -
-
+
+
{key} = {origin.value}
-
+
Scope: {formatConfigScope(origin)}
-
- File: {formatConfigPath(origin, repoPath)} +
+ File:{' '} + + {formatConfigPath(origin, repoPath)} +
) @@ -123,10 +149,20 @@ export class GitConfig extends React.Component { } return ( -
+

Resolved effective identity

- {nameOrigin && this.renderOriginEntry('user.name', nameOrigin)} - {emailOrigin && this.renderOriginEntry('user.email', emailOrigin)} + {nameOrigin && + this.renderOriginEntry( + 'user.name', + nameOrigin, + this.onRevealNameConfigFile + )} + {emailOrigin && + this.renderOriginEntry( + 'user.email', + emailOrigin, + this.onRevealEmailConfigFile + )}
) } diff --git a/app/styles/ui/dialogs/_repository-settings.scss b/app/styles/ui/dialogs/_repository-settings.scss index aa06585ba99..c2d9eb06ee8 100644 --- a/app/styles/ui/dialogs/_repository-settings.scss +++ b/app/styles/ui/dialogs/_repository-settings.scss @@ -58,23 +58,22 @@ padding-right: 0; } - .git-config-origin-hint { + .config-origin-hint { margin-top: var(--spacing); - color: var(--text-secondary-color); - .git-config-origin-card { + .config-origin-card { margin-top: var(--spacing-half); padding: var(--spacing-half) var(--spacing); background-color: var(--box-alt-background-color); border-radius: var(--border-radius); - .git-config-origin-key { + .config-origin-key { font-family: var(--font-family-monospace); font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); } - .git-config-origin-detail { + .config-origin-detail { font-size: var(--font-size-xs); word-break: break-all; } From 92e22fb0d28986fd8b1dfa7ce17534be1da935c5 Mon Sep 17 00:00:00 2001 From: voiduin <62399526+voiduin@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:09:57 +0300 Subject: [PATCH 4/4] feat: add interactive tooltip and catch --- app/src/lib/git/config.ts | 24 ++++++++----- app/src/ui/changes/commit-message.tsx | 49 ++++++++++++++++++++++---- app/src/ui/preferences/preferences.tsx | 11 ++++-- 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/app/src/lib/git/config.ts b/app/src/lib/git/config.ts index f98322b8063..b61c414f2db 100644 --- a/app/src/lib/git/config.ts +++ b/app/src/lib/git/config.ts @@ -340,13 +340,22 @@ export function getOriginFilePath( } /** - * Format a human-readable scope description for a config value origin. - * Detects whether a global-scoped value comes from a standard location - * (~/.gitconfig or ~/.config/git/config) vs. a conditionally included file - * (via includeIf directive). + * Check whether a global-scoped config value comes from a conditionally + * included file (via includeIf directive) rather than a standard location. */ -export function formatConfigScope(origin: IConfigValueOrigin): string { +export function isConditionalInclude(origin: IConfigValueOrigin): boolean { + if (origin.scope !== 'global') { + return false + } const filePath = getOriginFilePath(origin) + return ( + !/[/\\]\.gitconfig$/i.test(filePath) && + !/[/\\]\.config[/\\]git[/\\]config$/i.test(filePath) + ) +} + +/** Format a human-readable scope description for a config value origin. */ +export function formatConfigScope(origin: IConfigValueOrigin): string { if (origin.scope === 'local') { return 'local' } else if (origin.scope === 'system') { @@ -354,10 +363,7 @@ export function formatConfigScope(origin: IConfigValueOrigin): string { } else if (origin.scope === 'worktree') { return 'worktree' } else if (origin.scope === 'global') { - const isStandardGlobalPath = - /[/\\]\.gitconfig$/i.test(filePath) || - /[/\\]\.config[/\\]git[/\\]config$/i.test(filePath) - return isStandardGlobalPath ? 'global' : 'global, via [includeIf]' + return isConditionalInclude(origin) ? 'global, via [includeIf]' : 'global' } return origin.scope } diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx index 183dd914328..165ad37f88b 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -40,6 +40,8 @@ import { import { setGlobalConfigValue, IConfigValueOrigin, + getOriginFilePath, + isConditionalInclude, formatConfigScope, formatConfigPath, } from '../../lib/git/config' @@ -73,25 +75,26 @@ import { } from '../../lib/feature-flag' import { AriaLiveContainer } from '../accessibility/aria-live-container' import { TooltippedContent } from '../lib/tooltipped-content' +import { showItemInFolder } from '../main-process-proxy' import { HookProgress } from '../../lib/git' import { assertNever } from '../../lib/fatal-error' function renderScopeValue(origin: IConfigValueOrigin): JSX.Element { - const scope = formatConfigScope(origin) - if (scope.includes('includeIf')) { + if (isConditionalInclude(origin)) { return ( global, via [includeIf] ) } - return {scope} + return {formatConfigScope(origin)} } function formatConfigOriginTooltip( fieldName: string, origin: IConfigValueOrigin, - repositoryPath: string + repositoryPath: string, + onRevealFile: () => void ): JSX.Element { return (
@@ -100,7 +103,9 @@ function formatConfigOriginTooltip( Scope: {renderScopeValue(origin)} File: - {formatConfigPath(origin, repositoryPath)} + + {formatConfigPath(origin, repositoryPath)} +
) } @@ -838,10 +843,20 @@ export class CommitMessage extends React.Component< const { commitAuthorNameOrigin, commitAuthorEmailOrigin } = this.props const repoPath = this.props.repository.path const nameTooltip = commitAuthorNameOrigin - ? formatConfigOriginTooltip('Name', commitAuthorNameOrigin, repoPath) + ? formatConfigOriginTooltip( + 'Name', + commitAuthorNameOrigin, + repoPath, + this.onRevealNameConfigFile + ) : undefined const emailTooltip = commitAuthorEmailOrigin - ? formatConfigOriginTooltip('Email', commitAuthorEmailOrigin, repoPath) + ? formatConfigOriginTooltip( + 'Email', + commitAuthorEmailOrigin, + repoPath, + this.onRevealEmailConfigFile + ) : undefined return ( @@ -852,6 +867,7 @@ export class CommitMessage extends React.Component< className="commit-author-name" tooltip={nameTooltip} tooltipClassName="config-origin" + interactive={true} > {commitAuthor.name} @@ -859,6 +875,7 @@ export class CommitMessage extends React.Component< className="commit-author-email" tooltip={emailTooltip} tooltipClassName="config-origin" + interactive={true} > {commitAuthor.email} @@ -867,6 +884,24 @@ export class CommitMessage extends React.Component< ) } + private onRevealNameConfigFile = () => { + const { commitAuthorNameOrigin } = this.props + if (commitAuthorNameOrigin) { + showItemInFolder( + getOriginFilePath(commitAuthorNameOrigin, this.props.repository.path) + ) + } + } + + private onRevealEmailConfigFile = () => { + const { commitAuthorEmailOrigin } = this.props + if (commitAuthorEmailOrigin) { + showItemInFolder( + getOriginFilePath(commitAuthorEmailOrigin, this.props.repository.path) + ) + } + } + private onUpdateUserEmail = async (email: string) => { await setGlobalConfigValue('user.email', email) this.props.onRefreshAuthor() diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index 3a0fcf522bf..92554346062 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -933,9 +933,14 @@ export class Preferences extends React.Component< this.state.initialCommitterName || this.state.initialCommitterEmail ) { - // User unchecked the box — remove identity from global config - await removeGlobalConfigValue('user.name') - await removeGlobalConfigValue('user.email') + // User unchecked the box — remove identity from global config. + // Ignore errors if values are already absent. + try { + await removeGlobalConfigValue('user.name') + } catch {} + try { + await removeGlobalConfigValue('user.email') + } catch {} shouldRefreshAuthor = true }