From dfcfc209cbe3aa015c0e3f9391d48a8614655438 Mon Sep 17 00:00:00 2001 From: hefanbo <2457774+hefanbo@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:46:41 +0800 Subject: [PATCH] feat(git): add user name/email configuration Add a "User Details" dialog (toolbar account icon) to view and edit the git author identity (user.name/user.email) in the local or global scope, with a "Use globally" toggle that clears any local override when set, and a Remove action that unsets the configured identity. --- package-lock.json | 13 +- .../user-config.integration.test.ts | 53 +++++++ src/git/git-service.ts | 54 ++++++- src/git/types.ts | 7 + src/panels/MainPanel.ts | 26 ++++ src/utils/message-bus.ts | 8 +- webview-ui/package-lock.json | 8 - .../src/components/layout/Toolbar.svelte | 59 +++++++- .../components/modals/UserDetailsModal.svelte | 143 ++++++++++++++++++ webview-ui/src/lib/i18n/en.ts | 15 ++ webview-ui/src/lib/i18n/ko.ts | 15 ++ webview-ui/src/lib/i18n/zh.ts | 15 ++ webview-ui/src/lib/types.ts | 6 + 13 files changed, 399 insertions(+), 23 deletions(-) create mode 100644 src/git/__tests__/integration/user-config.integration.test.ts create mode 100644 webview-ui/src/components/modals/UserDetailsModal.svelte diff --git a/package-lock.json b/package-lock.json index 518fdee..2cada43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "git-graph-plus", - "version": "0.3.12", + "version": "0.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-graph-plus", - "version": "0.3.12", + "version": "0.7.3", "license": "Apache-2.0", "dependencies": { "@vscode/codicons": "^0.0.45" @@ -1417,7 +1417,6 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1567,7 +1566,6 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1985,7 +1983,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3439,7 +3436,6 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -5581,7 +5577,6 @@ "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -5994,7 +5989,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6244,7 +6238,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6361,7 +6354,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6395,7 +6387,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/src/git/__tests__/integration/user-config.integration.test.ts b/src/git/__tests__/integration/user-config.integration.test.ts new file mode 100644 index 0000000..e129442 --- /dev/null +++ b/src/git/__tests__/integration/user-config.integration.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { GitService } from '../../git-service'; +import { createTempRepo, runGit, type TempRepo } from './helpers'; + +describe('GitService integration — user config', () => { + let repo: TempRepo; + let svc: GitService; + + beforeEach(() => { + repo = createTempRepo(); + svc = new GitService(repo.path); + // Isolate the global/system scopes so global reads are deterministic + // (otherwise they'd reflect the developer's own ~/.gitconfig). + svc.setExtraEnv({ GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }); + }); + afterEach(() => repo.cleanup()); + + it('reads local user.name/user.email', async () => { + const details = await svc.getUserDetails(); + expect(details.name.local).toBe('Test User'); + expect(details.email.local).toBe('test@example.com'); + expect(details.name.global).toBeNull(); + expect(details.email.global).toBeNull(); + }); + + it('returns null for keys that are not set', async () => { + runGit(repo.path, ['config', '--local', '--unset-all', 'user.name']); + const details = await svc.getUserDetails(); + expect(details.name.local).toBeNull(); + expect(details.email.local).toBe('test@example.com'); + }); + + it('setUserConfig writes to the local scope', async () => { + await svc.setUserConfig('user.name', 'Jane Doe', 'local'); + await svc.setUserConfig('user.email', 'jane@example.com', 'local'); + const details = await svc.getUserDetails(); + expect(details.name.local).toBe('Jane Doe'); + expect(details.email.local).toBe('jane@example.com'); + }); + + it('unsetUserConfig removes the key', async () => { + await svc.unsetUserConfig('user.name', 'local'); + const details = await svc.getUserDetails(); + expect(details.name.local).toBeNull(); + expect(details.email.local).toBe('test@example.com'); + }); + + it('setUserConfig rejects empty and flag-like values', async () => { + await expect(svc.setUserConfig('user.name', '', 'local')).rejects.toThrow(); + await expect(svc.setUserConfig('user.name', ' ', 'local')).rejects.toThrow(); + await expect(svc.setUserConfig('user.name', '-x', 'local')).rejects.toThrow(); + }); +}); diff --git a/src/git/git-service.ts b/src/git/git-service.ts index 71e6576..0137d46 100644 --- a/src/git/git-service.ts +++ b/src/git/git-service.ts @@ -15,7 +15,7 @@ import { resolveGitDirs } from '../services/file-watcher-helpers'; const DEFAULT_MAX_BUFFER_BYTES = 256 * 1024 * 1024; import { parseLog, parseBranches, parseTags, parseRemotes, parseStashList, parseDiff, parseWorktreeList, parseLfsFiles, parseLfsLocks, mapSignatureStatus } from './git-parser'; import { buildReversePatch } from './patch-builder'; -import type { Commit, BranchInfo, TagInfo, RemoteInfo, StashEntry, LogOptions, DiffData, WorktreeInfo, CommitSignature } from './types'; +import type { Commit, BranchInfo, TagInfo, RemoteInfo, StashEntry, LogOptions, DiffData, WorktreeInfo, CommitSignature, UserDetails } from './types'; export class GitError extends Error { constructor( @@ -1526,6 +1526,58 @@ export class GitService { this.cachedRemoteNames = null; } + /** Reads the `user.name` / `user.email` git config from both the local repo + * and the global user scope. Missing keys resolve to `null` (git `--get` + * exits non-zero when a key is absent, which we swallow here). */ + async getUserDetails(): Promise { + const get = async (key: 'user.name' | 'user.email', location: 'local' | 'global'): Promise => { + try { + const raw = await this.exec(['config', '--' + location, '--get', key], { silent: true }); + const value = raw.replace(/\r?\n$/, '').trim(); + return value.length > 0 ? value : null; + } catch { + return null; + } + }; + const [nameLocal, nameGlobal, emailLocal, emailGlobal] = await Promise.all([ + get('user.name', 'local'), + get('user.name', 'global'), + get('user.email', 'local'), + get('user.email', 'global'), + ]); + return { + name: { local: nameLocal, global: nameGlobal }, + email: { local: emailLocal, global: emailGlobal }, + }; + } + + /** Sets a `user.name` / `user.email` value in the local or global scope. */ + async setUserConfig(key: 'user.name' | 'user.email', value: string, location: 'local' | 'global'): Promise { + this.assertSafeConfigValue(value); + await this.exec(['config', '--' + location, key, value]); + } + + /** Removes all `user.name` / `user.email` values from the local or global scope. */ + async unsetUserConfig(key: 'user.name' | 'user.email', location: 'local' | 'global'): Promise { + await this.exec(['config', '--' + location, '--unset-all', key]); + } + + /** Reject config values git could misinterpret (flag-like, control chars) or + * that make no sense as an identity. Args are passed via spawn argv (no + * shell), but a leading `-` would still be parsed by git as an option. */ + private assertSafeConfigValue(value: string): void { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new GitError('Invalid config value', null, []); + } + if (value.startsWith('-')) { + throw new GitError(`Config value must not start with '-': ${value}`, null, []); + } + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f\x7f]/.test(value)) { + throw new GitError('Config value contains control characters', null, []); + } + } + async setUpstream(localBranch: string, remote: string, remoteBranch: string, options?: { createRemote?: boolean }): Promise { this.assertSafeRef(localBranch, 'setUpstream'); this.assertSafeRef(remote, 'setUpstream'); diff --git a/src/git/types.ts b/src/git/types.ts index feed7ab..3e1bfa4 100644 --- a/src/git/types.ts +++ b/src/git/types.ts @@ -35,6 +35,13 @@ export interface Ref { remote?: string; } +/** The author/committer identity git uses, resolved per scope (`local` repo + * config vs `global` user config). Either scope may be unset (`null`). */ +export interface UserDetails { + name: { local: string | null; global: string | null }; + email: { local: string | null; global: string | null }; +} + export interface GraphNode { commit: string; column: number; diff --git a/src/panels/MainPanel.ts b/src/panels/MainPanel.ts index 5869905..29396d4 100644 --- a/src/panels/MainPanel.ts +++ b/src/panels/MainPanel.ts @@ -775,6 +775,32 @@ export class MainPanel { await vscode.commands.executeCommand('workbench.action.openSettings', '@ext:the0807.git-graph-plus'); break; } + case 'getUserDetails': { + this.post({ type: 'userDetailsData', payload: await this.gitService.getUserDetails() }); + break; + } + case 'editUserDetails': { + const { name, email, location, deleteLocalName, deleteLocalEmail } = message.payload; + await this.gitService.setUserConfig('user.name', name, location); + await this.gitService.setUserConfig('user.email', email, location); + // When switching to the global scope, clear any local override so the + // global values actually take effect for this repo. + if (deleteLocalName) { + await this.gitService.unsetUserConfig('user.name', 'local'); + } + if (deleteLocalEmail) { + await this.gitService.unsetUserConfig('user.email', 'local'); + } + this.post({ type: 'operationComplete', payload: { operation: 'editUserDetails', success: true } }); + break; + } + case 'deleteUserDetails': { + const { name, email, location } = message.payload; + if (name) await this.gitService.unsetUserConfig('user.name', location); + if (email) await this.gitService.unsetUserConfig('user.email', location); + this.post({ type: 'operationComplete', payload: { operation: 'deleteUserDetails', success: true } }); + break; + } case 'amendCommit': { await this.gitService.amendCommit(message.payload); // Optional follow-up: amend rewrites HEAD, so the push force-pushes diff --git a/src/utils/message-bus.ts b/src/utils/message-bus.ts index deec2ab..9328c70 100644 --- a/src/utils/message-bus.ts +++ b/src/utils/message-bus.ts @@ -1,4 +1,4 @@ -import type { CommitGraphData, BranchData, DiffData, Commit, WorktreeInfo, CommitSignature } from '../git/types'; +import type { CommitGraphData, BranchData, DiffData, Commit, WorktreeInfo, CommitSignature, UserDetails } from '../git/types'; export interface LinkRule { pattern: string; @@ -127,7 +127,10 @@ export type WebviewMessage = | { type: 'getMultiCommitSections'; payload: { hashes: string[] } } | { type: 'getAvatar'; payload: { email: string; size: number } } | { type: 'openExternalUrl'; payload: { url: string } } - | { type: 'openExtensionSettings' }; + | { type: 'openExtensionSettings' } + | { type: 'getUserDetails' } + | { type: 'editUserDetails'; payload: { name: string; email: string; location: 'local' | 'global'; deleteLocalName?: boolean; deleteLocalEmail?: boolean } } + | { type: 'deleteUserDetails'; payload: { name: boolean; email: boolean; location: 'local' | 'global' } }; // Messages from Extension → Webview export type ExtensionMessage = @@ -169,6 +172,7 @@ export type ExtensionMessage = | { type: 'avatarData'; payload: { email: string; size: number; dataUri: string | null } } | { type: 'conflictData'; payload: { operation: string; files: Array<{ path: string; resolved: boolean }> } } | { type: 'flowStatus'; payload: { installed: boolean; initialized: boolean; config: { productionBranch: string; developBranch: string; featurePrefix: string; releasePrefix: string; hotfixPrefix: string; versionTagPrefix: string } | null } } + | { type: 'userDetailsData'; payload: UserDetails } | { type: 'flowBranches'; payload: { features: string[]; releases: string[]; hotfixes: string[] } } | { type: 'defaultBranch'; payload: { name: string | null } } | { type: 'showModal'; payload: diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 6050171..13fd799 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -985,7 +985,6 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1335,7 +1334,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1718,7 +1716,6 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -2037,7 +2034,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2300,7 +2296,6 @@ "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2424,7 +2419,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2542,7 +2536,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -2661,7 +2654,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/webview-ui/src/components/layout/Toolbar.svelte b/webview-ui/src/components/layout/Toolbar.svelte index 6ceb091..f10f211 100644 --- a/webview-ui/src/components/layout/Toolbar.svelte +++ b/webview-ui/src/components/layout/Toolbar.svelte @@ -6,11 +6,12 @@ import { t } from '../../lib/i18n/index.svelte'; import AddRemoteModal from '../modals/AddRemoteModal.svelte'; import NoRemotesErrorModal from '../modals/NoRemotesErrorModal.svelte'; + import UserDetailsModal from '../modals/UserDetailsModal.svelte'; import { tooltip } from '../../lib/actions/tooltip'; import { modalStore } from '../../lib/stores/modals.svelte'; import { commitStore } from '../../lib/stores/commits.svelte'; import { samePath } from '../../lib/utils/path'; - import type { FlowStatus, FlowBranches } from '../../lib/types'; + import type { FlowStatus, FlowBranches, UserDetails } from '../../lib/types'; const vscode = getVsCodeApi(); @@ -26,6 +27,8 @@ let flowStatus = $state(null); let flowBranches = $state({ features: [], releases: [], hotfixes: [] }); let showNoRemotesError = $state(false); + let showUserDetails = $state(false); + let userDetails = $state(null); function refresh() { uiStore.operating = 'refresh'; @@ -69,6 +72,42 @@ vscode.postMessage({ type: 'switchRepo', payload: { path: repoPath } }); } + function openUserDetails() { + userDetails = null; + showUserDetails = true; + vscode.postMessage({ type: 'getUserDetails' }); + } + + function saveUserDetails(name: string, email: string, useGlobally: boolean) { + const ud = userDetails; + showUserDetails = false; + vscode.postMessage({ + type: 'editUserDetails', + payload: { + name, + email, + location: useGlobally ? 'global' : 'local', + deleteLocalName: useGlobally && ud !== null && ud.name.local !== null, + deleteLocalEmail: useGlobally && ud !== null && ud.email.local !== null, + }, + }); + } + + function removeUserDetails() { + const ud = userDetails; + showUserDetails = false; + if (ud === null) return; + const isGlobal = ud.name.local === null && ud.email.local === null; + vscode.postMessage({ + type: 'deleteUserDetails', + payload: { + name: (isGlobal ? ud.name.global : ud.name.local) !== null, + email: (isGlobal ? ud.email.global : ud.email.local) !== null, + location: isGlobal ? 'global' : 'local', + }, + }); + } + onMount(() => { // Note: App.svelte also listens for `message` events. The two handlers write to // disjoint state (App: rebasePaused/conflict, this: uiStore.operating) and read @@ -86,6 +125,7 @@ if (msg.type === 'flowStatus') flowStatus = msg.payload; if (msg.type === 'flowBranches') flowBranches = msg.payload; if (msg.type === 'defaultBranch') defaultBranch = msg.payload.name; + if (msg.type === 'userDetailsData') userDetails = msg.payload; } window.addEventListener('message', handler); return () => window.removeEventListener('message', handler); @@ -394,6 +434,14 @@ > {#if uiStore.operating === 'refresh'}{:else}{/if} +