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..b61c414f2db 100644 --- a/app/src/lib/git/config.ts +++ b/app/src/lib/git/config.ts @@ -282,3 +282,111 @@ 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', + // 0 = found, 1 = key not set, 128 = not a git repo or git error + { 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 +} + +/** + * 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 +} + +/** + * Check whether a global-scoped config value comes from a conditionally + * included file (via includeIf directive) rather than a standard location. + */ +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') { + return 'system' + } else if (origin.scope === 'worktree') { + return 'worktree' + } else if (origin.scope === 'global') { + return isConditionalInclude(origin) ? 'global, via [includeIf]' : 'global' + } + return origin.scope +} + +/** + * Format the file path for a config value origin. + * For local/worktree scope, displays the path with a `` prefix. + */ +export function formatConfigPath( + origin: IConfigValueOrigin, + repositoryPath: string +): string { + 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 rawPath +} diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 464440e8456..d16753631d8 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -301,6 +301,7 @@ 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 +465,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 +614,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 +1229,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 +2596,10 @@ export class AppStore extends TypedBaseStore { commitSpellcheckEnabledKey, commitSpellcheckEnabledDefault ) + this.showCommitAuthorInfo = getBoolean( + showCommitAuthorInfoKey, + showCommitAuthorInfoDefault + ) this.showSideBySideDiff = getShowSideBySideDiff() this.selectedTheme = getPersistedThemeName() @@ -4225,6 +4235,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 +4318,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..165ad37f88b 100644 --- a/app/src/ui/changes/commit-message.tsx +++ b/app/src/ui/changes/commit-message.tsx @@ -37,7 +37,14 @@ import { isAttributableEmailFor, lookupPreferredEmail, } from '../../lib/email' -import { setGlobalConfigValue } from '../../lib/git/config' +import { + setGlobalConfigValue, + IConfigValueOrigin, + getOriginFilePath, + isConditionalInclude, + 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,9 +74,42 @@ import { enableHooksEnvironment, } 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 { + if (isConditionalInclude(origin)) { + return ( + + global, via [includeIf] + + ) + } + return {formatConfigScope(origin)} +} + +function formatConfigOriginTooltip( + fieldName: string, + origin: IConfigValueOrigin, + repositoryPath: string, + onRevealFile: () => void +): JSX.Element { + return ( +
+ {fieldName}: + {origin.value} + Scope: + {renderScopeValue(origin)} + File: + + {formatConfigPath(origin, repositoryPath)} + +
+ ) +} + const addAuthorIcon: OcticonSymbolVariant = { w: 18, h: 13, @@ -240,6 +280,10 @@ interface ICommitMessageProps { repository: Repository, options: Partial ) => void + + readonly commitAuthorNameOrigin?: IConfigValueOrigin | null + readonly commitAuthorEmailOrigin?: IConfigValueOrigin | null + readonly showCommitAuthorInfo?: boolean } interface ICommitMessageState { @@ -770,7 +814,7 @@ export class CommitMessage extends React.Component< } } - return ( + const avatar = ( ) + + if (!this.props.showCommitAuthorInfo || !commitAuthor) { + return avatar + } + + const { commitAuthorNameOrigin, commitAuthorEmailOrigin } = this.props + const repoPath = this.props.repository.path + const nameTooltip = commitAuthorNameOrigin + ? formatConfigOriginTooltip( + 'Name', + commitAuthorNameOrigin, + repoPath, + this.onRevealNameConfigFile + ) + : undefined + const emailTooltip = commitAuthorEmailOrigin + ? formatConfigOriginTooltip( + 'Email', + commitAuthorEmailOrigin, + repoPath, + this.onRevealEmailConfigFile + ) + : undefined + + return ( +
+ {avatar} +
+ + {commitAuthor.name} + + + {commitAuthor.email} + +
+
+ ) + } + + 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) => { @@ -1738,9 +1847,12 @@ export class CommitMessage extends React.Component< onContextMenu={this.onContextMenu} ref={this.wrapperRef} > -
- {this.renderAvatar()} + {/* 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()} +
+ {!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/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 a2368e5dc9e..92554346062 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' @@ -73,6 +74,7 @@ interface IPreferencesProps { readonly onDismissed: () => void readonly useWindowsOpenSSH: boolean readonly showCommitLengthWarning: boolean + readonly showCommitAuthorInfo: boolean readonly notificationsEnabled: boolean readonly optOutOfUsageTracking: boolean readonly useExternalCredentialHelper: boolean @@ -119,9 +121,11 @@ 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 + readonly showCommitAuthorInfo: boolean readonly notificationsEnabled: boolean readonly optOutOfUsageTracking: boolean readonly useExternalCredentialHelper: boolean @@ -206,6 +210,7 @@ export class Preferences extends React.Component< initialCommitterName: null, initialCommitterEmail: null, initialDefaultBranch: null, + setGlobalAuthor: false, disallowedCharactersMessage: null, availableEditors: [], useCustomEditor: this.props.useCustomEditor, @@ -216,6 +221,7 @@ export class Preferences extends React.Component< this.props.branchPresetScript ?? DefaultCustomIntegration, useWindowsOpenSSH: false, showCommitLengthWarning: false, + showCommitAuthorInfo: false, notificationsEnabled: true, optOutOfUsageTracking: false, useExternalCredentialHelper: false, @@ -296,8 +302,10 @@ export class Preferences extends React.Component< initialCommitterName, initialCommitterEmail, initialDefaultBranch, + setGlobalAuthor: !!initialCommitterName || !!initialCommitterEmail, useWindowsOpenSSH: this.props.useWindowsOpenSSH, showCommitLengthWarning: this.props.showCommitLengthWarning, + showCommitAuthorInfo: this.props.showCommitAuthorInfo, notificationsEnabled: this.props.notificationsEnabled, optOutOfUsageTracking: this.props.optOutOfUsageTracking, useExternalCredentialHelper: this.props.useExternalCredentialHelper, @@ -557,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} /> ) @@ -719,6 +735,14 @@ export class Preferences extends React.Component< this.setState({ showCommitLengthWarning }) } + private onShowCommitAuthorInfoChanged = (showCommitAuthorInfo: boolean) => { + this.setState({ showCommitAuthorInfo }) + } + + private onSetGlobalAuthorChanged = (setGlobalAuthor: boolean) => { + this.setState({ setGlobalAuthor }) + } + private onNotificationsEnabledChanged = (notificationsEnabled: boolean) => { this.setState({ notificationsEnabled }) } @@ -895,13 +919,28 @@ 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. + // Ignore errors if values are already absent. + try { + await removeGlobalConfigValue('user.name') + } catch {} + try { + await removeGlobalConfigValue('user.email') + } catch {} shouldRefreshAuthor = true } @@ -983,6 +1022,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..585ab0d1440 100644 --- a/app/src/ui/repository-settings/git-config.tsx +++ b/app/src/ui/repository-settings/git-config.tsx @@ -4,7 +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 { @@ -17,6 +25,10 @@ interface IGitConfigProps { readonly globalEmail: string readonly isLoadingGitConfig: boolean + readonly nameOrigin?: IConfigValueOrigin | null + readonly emailOrigin?: IConfigValueOrigin | null + readonly repositoryPath: string + readonly onGitConfigLocationChanged: (value: GitConfigLocation) => void readonly onNameChanged: (name: string) => void readonly onEmailChanged: (email: string) => void @@ -85,7 +97,73 @@ export class GitConfig extends React.Component { isLoadingGitConfig={this.props.isLoadingGitConfig} />
+ {this.renderConfigOrigin()} ) } + + 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)} + +
+
+ ) + } + + private renderConfigOrigin() { + const { nameOrigin, emailOrigin } = this.props + if (!nameOrigin && !emailOrigin) { + return null + } + + return ( +
+

Resolved effective identity

+ {nameOrigin && + this.renderOriginEntry( + 'user.name', + nameOrigin, + this.onRevealNameConfigFile + )} + {emailOrigin && + this.renderOriginEntry( + 'user.email', + emailOrigin, + this.onRevealEmailConfigFile + )} +
+ ) + } } diff --git a/app/src/ui/repository-settings/repository-settings.tsx b/app/src/ui/repository-settings/repository-settings.tsx index 18277b2ce4b..bc1f654fd3f 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,9 @@ export class RepositorySettings extends React.Component< onNameChanged={this.onCommitterNameChanged} onEmailChanged={this.onCommitterEmailChanged} isLoadingGitConfig={this.state.isLoadingGitConfig} + nameOrigin={this.state.nameOrigin} + emailOrigin={this.state.emailOrigin} + repositoryPath={this.props.repository.path} /> ) } 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; diff --git a/app/styles/ui/dialogs/_repository-settings.scss b/app/styles/ui/dialogs/_repository-settings.scss index b1552ecf49c..c2d9eb06ee8 100644 --- a/app/styles/ui/dialogs/_repository-settings.scss +++ b/app/styles/ui/dialogs/_repository-settings.scss @@ -57,4 +57,26 @@ padding-left: 0; padding-right: 0; } + + .config-origin-hint { + margin-top: var(--spacing); + + .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); + + .config-origin-key { + font-family: var(--font-family-monospace); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + } + + .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);