Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 2 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions src/git/__tests__/integration/user-config.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
54 changes: 53 additions & 1 deletion src/git/git-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<UserDetails> {
const get = async (key: 'user.name' | 'user.email', location: 'local' | 'global'): Promise<string | null> => {
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<void> {
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<void> {
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<void> {
this.assertSafeRef(localBranch, 'setUpstream');
this.assertSafeRef(remote, 'setUpstream');
Expand Down
7 changes: 7 additions & 0 deletions src/git/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions src/panels/MainPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/utils/message-bus.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 0 additions & 8 deletions webview-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading