Skip to content

Commit e122179

Browse files
nullvariantclaude
andauthored
feat(git-id-switcher): add sync checker engine (#358)
* feat(git-id-switcher): add sync checker engine for profile vs git config comparison Add syncChecker module with pure comparison logic and async orchestration for detecting mismatches between selected identity profiles and actual git config values (user.name, user.email, user.signingkey). Includes 27 unit tests covering all comparison paths, icon stripping, GPG key handling, cancellation, and error recovery. 🖥️ IDE: [Cursor](https://cursor.sh) 🔌 Extension: [Claude Code](https://claude.ai/download) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Model-Raw: claude-opus-4-6-20250514 * test(git-id-switcher): mark production-only branch with c8 ignore for full coverage The ?? fallback to getCurrentGitConfig is unreachable in test environment (no VS Code API), so mark it as intentional. 🖥️ IDE: [Cursor](https://cursor.sh) 🔌 Extension: [Claude Code](https://claude.ai/download) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Model-Raw: claude-opus-4-6-20250514 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b9938b7 commit e122179

5 files changed

Lines changed: 781 additions & 1 deletion

File tree

extensions/git-id-switcher/src/core/gitConfig.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function t(message: string): string {
3838
* Check if icon should be included in Git config user.name
3939
* @returns true if icon should be included, false otherwise (default: false)
4040
*/
41-
function shouldIncludeIconInGitConfig(): boolean {
41+
export function shouldIncludeIconInGitConfig(): boolean {
4242
const workspace = getWorkspace();
4343
if (!workspace) {
4444
return false;
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* Sync Checker - Profile vs Git Config Synchronization Detection
3+
*
4+
* Detects mismatches between the selected identity profile and
5+
* the actual git config (user.name, user.email, user.signingkey).
6+
*
7+
* Design:
8+
* - compareSyncState() is a pure function for testability
9+
* - stripIcon() reverses buildGitUserName()'s icon prepend
10+
* - checkSync() orchestrates git config reading and comparison
11+
*
12+
* @sideeffect checkSync() executes `git config --local` via getCurrentGitConfig()
13+
*/
14+
15+
import type * as vscodeTypes from 'vscode';
16+
import type { Identity } from '../identity/identity';
17+
import type { GitConfig } from './gitConfig';
18+
import { getCurrentGitConfig, shouldIncludeIconInGitConfig } from './gitConfig';
19+
20+
// ============================================================================
21+
// Types
22+
// ============================================================================
23+
24+
export type SyncState = 'synced' | 'out_of_sync' | 'unknown';
25+
26+
export interface SyncMismatch {
27+
field: 'name' | 'email' | 'signingKey';
28+
expected: string;
29+
actual: string;
30+
}
31+
32+
export interface SyncCheckResult {
33+
state: SyncState;
34+
mismatches: SyncMismatch[];
35+
}
36+
37+
// ============================================================================
38+
// Pure Functions (no side effects, fully testable)
39+
// ============================================================================
40+
41+
/**
42+
* Strip leading emoji icon from a git user.name value.
43+
*
44+
* Reverses the transformation applied by buildGitUserName():
45+
* `${identity.icon} ${identity.name}` → `identity.name`
46+
*
47+
* The icon is always a single grapheme cluster followed by a space.
48+
* We detect this by checking if the first space-separated token
49+
* matches the identity's icon.
50+
*
51+
* @param gitUserName - The user.name value from git config
52+
* @param icon - The identity's icon (emoji), or undefined if no icon
53+
* @returns The user.name with icon stripped, or unchanged if no icon match
54+
*/
55+
export function stripIcon(gitUserName: string, icon: string | undefined): string {
56+
if (!icon) {
57+
return gitUserName;
58+
}
59+
60+
const prefix = `${icon} `;
61+
if (gitUserName.startsWith(prefix)) {
62+
return gitUserName.slice(prefix.length);
63+
}
64+
65+
return gitUserName;
66+
}
67+
68+
/**
69+
* Compare an identity profile against actual git config values.
70+
*
71+
* Pure function: no I/O, no side effects.
72+
*
73+
* @param identity - The selected identity profile
74+
* @param gitConfig - The actual git config values
75+
* @param includeIconInGitConfig - Whether icons are included in git config user.name
76+
* @returns SyncCheckResult with state and any mismatches
77+
*/
78+
export function compareSyncState(
79+
identity: Readonly<Identity>,
80+
gitConfig: Readonly<GitConfig>,
81+
includeIconInGitConfig: boolean,
82+
): SyncCheckResult {
83+
// If git config is completely empty, state is unknown (likely not a git repo)
84+
if (gitConfig.userName === undefined && gitConfig.userEmail === undefined) {
85+
return { state: 'unknown', mismatches: [] };
86+
}
87+
88+
const mismatches: SyncMismatch[] = [];
89+
90+
// Compare user.name
91+
if (gitConfig.userName !== undefined) {
92+
const actualName = includeIconInGitConfig
93+
? stripIcon(gitConfig.userName, identity.icon)
94+
: gitConfig.userName;
95+
96+
if (actualName !== identity.name) {
97+
mismatches.push({
98+
field: 'name',
99+
expected: identity.name,
100+
actual: gitConfig.userName,
101+
});
102+
}
103+
}
104+
105+
// Compare user.email
106+
if (gitConfig.userEmail !== undefined && gitConfig.userEmail !== identity.email) {
107+
mismatches.push({
108+
field: 'email',
109+
expected: identity.email,
110+
actual: gitConfig.userEmail,
111+
});
112+
}
113+
114+
// Compare user.signingkey (only if identity has a GPG key configured)
115+
if (
116+
identity.gpgKeyId
117+
&& gitConfig.signingKey !== undefined
118+
&& gitConfig.signingKey !== identity.gpgKeyId
119+
) {
120+
mismatches.push({
121+
field: 'signingKey',
122+
expected: identity.gpgKeyId,
123+
actual: gitConfig.signingKey,
124+
});
125+
}
126+
127+
return {
128+
state: mismatches.length === 0 ? 'synced' : 'out_of_sync',
129+
mismatches,
130+
};
131+
}
132+
133+
// ============================================================================
134+
// Side-effecting Functions
135+
// ============================================================================
136+
137+
type GitConfigReader = (token?: vscodeTypes.CancellationToken) => Promise<GitConfig>;
138+
139+
/**
140+
* Override for getCurrentGitConfig, used in tests.
141+
* Not intended for production use.
142+
*/
143+
let gitConfigReaderOverride: GitConfigReader | undefined;
144+
145+
/**
146+
* Set a mock git config reader for testing.
147+
* Not intended for production use.
148+
*/
149+
export function _setGitConfigReader(reader: GitConfigReader | undefined): void {
150+
gitConfigReaderOverride = reader;
151+
}
152+
153+
/**
154+
* Check synchronization between an identity profile and actual git config.
155+
*
156+
* @sideeffect Executes `git config --local` 3 times (user.name, user.email, user.signingkey)
157+
*
158+
* @param identity - The selected identity profile
159+
* @param includeIconInGitConfig - Whether icons are included in git config user.name
160+
* @param token - Optional cancellation token for aborting the operation
161+
* @returns SyncCheckResult indicating sync state and any mismatches
162+
*/
163+
export async function checkSync(
164+
identity: Readonly<Identity>,
165+
includeIconInGitConfig?: boolean,
166+
token?: vscodeTypes.CancellationToken,
167+
): Promise<SyncCheckResult> {
168+
if (token?.isCancellationRequested) {
169+
return { state: 'unknown', mismatches: [] };
170+
}
171+
172+
try {
173+
/* c8 ignore next - production path: test environment has no VS Code API */
174+
const readGitConfig = gitConfigReaderOverride ?? getCurrentGitConfig;
175+
const gitConfig = await readGitConfig(token);
176+
177+
if (token?.isCancellationRequested) {
178+
return { state: 'unknown', mismatches: [] };
179+
}
180+
181+
const iconSetting = includeIconInGitConfig ?? shouldIncludeIconInGitConfig();
182+
return compareSyncState(identity, gitConfig, iconSetting);
183+
} catch {
184+
// Git config read failure (not a git repo, git not installed, etc.)
185+
return { state: 'unknown', mismatches: [] };
186+
}
187+
}

extensions/git-id-switcher/src/test/runTests.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { runSubmoduleTests } from './submodule.test';
2828
import { runPathSeparatorTests } from './pathSeparator.test';
2929
import { runDisplayLimitsTests } from './displayLimits.test';
3030
import { runSshAgentParsingTests } from './sshAgentParsing.test';
31+
import { runSyncCheckerTests } from './syncChecker.test';
3132

3233
async function main(): Promise<void> {
3334
console.log('╔════════════════════════════════════════════╗');
@@ -107,6 +108,9 @@ async function main(): Promise<void> {
107108
// Run SSH agent parsing tests (ReDoS-safe split-based parsing)
108109
await runSshAgentParsingTests();
109110

111+
// Run sync checker tests (profile vs git config comparison)
112+
await runSyncCheckerTests();
113+
110114
console.log('╔════════════════════════════════════════════╗');
111115
console.log('║ 🎉 All Security Tests Passed! ║');
112116
console.log('╚════════════════════════════════════════════╝\n');

extensions/git-id-switcher/src/test/runUnitTests.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { runSubmoduleTests } from './submodule.test';
1919
import { runPathSeparatorTests } from './pathSeparator.test';
2020
import { runDisplayLimitsTests } from './displayLimits.test';
2121
import { runSshAgentParsingTests } from './sshAgentParsing.test';
22+
import { runSyncCheckerTests } from './syncChecker.test';
2223

2324
async function main(): Promise<void> {
2425
console.log('╔════════════════════════════════════════════╗');
@@ -71,6 +72,9 @@ async function main(): Promise<void> {
7172
// Run SSH agent parsing tests (ReDoS-safe split-based parsing)
7273
await runSshAgentParsingTests();
7374

75+
// Run sync checker tests (profile vs git config comparison)
76+
await runSyncCheckerTests();
77+
7478
console.log('╔════════════════════════════════════════════╗');
7579
console.log('║ 🎉 All Unit Tests Passed! ║');
7680
console.log('╚════════════════════════════════════════════╝\n');

0 commit comments

Comments
 (0)