From 3abc41f1225d415f7b84fca3469ee4620c3d56df Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Sat, 21 Feb 2026 14:49:46 -0500 Subject: [PATCH] refactor git widgets to use shared git command helpers Centralize cwd resolution and git command execution for git widgets, and expand widget-level and utility tests for failure and edge cases. Closes #176 --- src/utils/__tests__/git.test.ts | 137 ++++++++++++++++++++++ src/utils/git.ts | 38 ++++++ src/widgets/GitBranch.ts | 24 ++-- src/widgets/GitChanges.ts | 61 +++++----- src/widgets/GitRootDir.ts | 24 ++-- src/widgets/GitWorktree.ts | 48 +++++--- src/widgets/__tests__/GitBranch.test.ts | 106 +++++++++++++++++ src/widgets/__tests__/GitChanges.test.ts | 100 ++++++++++++++++ src/widgets/__tests__/GitRootDir.test.ts | 41 +++++-- src/widgets/__tests__/GitWorktree.test.ts | 72 +++++++++--- 10 files changed, 548 insertions(+), 103 deletions(-) create mode 100644 src/utils/__tests__/git.test.ts create mode 100644 src/utils/git.ts create mode 100644 src/widgets/__tests__/GitBranch.test.ts create mode 100644 src/widgets/__tests__/GitChanges.test.ts diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts new file mode 100644 index 0000000..19e1201 --- /dev/null +++ b/src/utils/__tests__/git.test.ts @@ -0,0 +1,137 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + isInsideGitWorkTree, + resolveGitCwd, + runGit +} from '../git'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; +}; + +describe('git utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('resolveGitCwd', () => { + it('prefers context.data.cwd when available', () => { + const context: RenderContext = { + data: { + cwd: '/repo/from/cwd', + workspace: { + current_dir: '/repo/from/current-dir', + project_dir: '/repo/from/project-dir' + } + } + }; + + expect(resolveGitCwd(context)).toBe('/repo/from/cwd'); + }); + + it('falls back to workspace.current_dir', () => { + const context: RenderContext = { + data: { + workspace: { + current_dir: '/repo/from/current-dir', + project_dir: '/repo/from/project-dir' + } + } + }; + + expect(resolveGitCwd(context)).toBe('/repo/from/current-dir'); + }); + + it('falls back to workspace.project_dir', () => { + const context: RenderContext = { data: { workspace: { project_dir: '/repo/from/project-dir' } } }; + + expect(resolveGitCwd(context)).toBe('/repo/from/project-dir'); + }); + + it('skips empty candidate values', () => { + const context: RenderContext = { + data: { + cwd: ' ', + workspace: { + current_dir: '', + project_dir: '/repo/from/project-dir' + } + } + }; + + expect(resolveGitCwd(context)).toBe('/repo/from/project-dir'); + }); + + it('returns undefined when no candidates are available', () => { + expect(resolveGitCwd({})).toBeUndefined(); + }); + }); + + describe('runGit', () => { + it('runs git command with resolved cwd and trims output', () => { + mockExecSync.mockReturnValue(' feature/worktree \n'); + const context: RenderContext = { data: { cwd: '/tmp/repo' } }; + + const result = runGit('branch --show-current', context); + + expect(result).toBe('feature/worktree'); + expect(mockExecSync.mock.calls[0]?.[0]).toBe('git branch --show-current'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/repo' + }); + }); + + it('runs git command without cwd when no context directory exists', () => { + mockExecSync.mockReturnValue('true\n'); + + const result = runGit('rev-parse --is-inside-work-tree', {}); + + expect(result).toBe('true'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }); + }); + + it('returns null when the command fails', () => { + mockExecSync.mockImplementation(() => { throw new Error('git failed'); }); + + expect(runGit('status --short', {})).toBeNull(); + }); + }); + + describe('isInsideGitWorkTree', () => { + it('returns true when git reports true', () => { + mockExecSync.mockReturnValue('true\n'); + + expect(isInsideGitWorkTree({})).toBe(true); + }); + + it('returns false when git reports false', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(isInsideGitWorkTree({})).toBe(false); + }); + + it('returns false when git command fails', () => { + mockExecSync.mockImplementation(() => { throw new Error('git failed'); }); + + expect(isInsideGitWorkTree({})).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/git.ts b/src/utils/git.ts new file mode 100644 index 0000000..3f768ab --- /dev/null +++ b/src/utils/git.ts @@ -0,0 +1,38 @@ +import { execSync } from 'child_process'; + +import type { RenderContext } from '../types/RenderContext'; + +export function resolveGitCwd(context: RenderContext): string | undefined { + const candidates = [ + context.data?.cwd, + context.data?.workspace?.current_dir, + context.data?.workspace?.project_dir + ]; + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate; + } + } + + return undefined; +} + +export function runGit(command: string, context: RenderContext): string | null { + try { + const cwd = resolveGitCwd(context); + const output = execSync(`git ${command}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + ...(cwd ? { cwd } : {}) + }).trim(); + + return output.length > 0 ? output : null; + } catch { + return null; + } +} + +export function isInsideGitWorkTree(context: RenderContext): boolean { + return runGit('rev-parse --is-inside-work-tree', context) === 'true'; +} \ No newline at end of file diff --git a/src/widgets/GitBranch.ts b/src/widgets/GitBranch.ts index 5f928ae..080f142 100644 --- a/src/widgets/GitBranch.ts +++ b/src/widgets/GitBranch.ts @@ -1,5 +1,3 @@ -import { execSync } from 'child_process'; - import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { @@ -8,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + isInsideGitWorkTree, + runGit +} from '../utils/git'; export class GitBranchWidget implements Widget { getDefaultColor(): string { return 'magenta'; } @@ -49,23 +51,19 @@ export class GitBranchWidget implements Widget { return item.rawValue ? 'main' : '⎇ main'; } - const branch = this.getGitBranch(); + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '⎇ no git'; + } + + const branch = this.getGitBranch(context); if (branch) return item.rawValue ? branch : `⎇ ${branch}`; return hideNoGit ? null : '⎇ no git'; } - private getGitBranch(): string | null { - try { - const branch = execSync('git branch --show-current', { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'] - }).trim(); - return branch || null; - } catch { - return null; - } + private getGitBranch(context: RenderContext): string | null { + return runGit('branch --show-current', context); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/GitChanges.ts b/src/widgets/GitChanges.ts index 03b64ee..56dc2ab 100644 --- a/src/widgets/GitChanges.ts +++ b/src/widgets/GitChanges.ts @@ -1,5 +1,3 @@ -import { execSync } from 'child_process'; - import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { @@ -8,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + isInsideGitWorkTree, + runGit +} from '../utils/git'; export class GitChangesWidget implements Widget { getDefaultColor(): string { return 'yellow'; } @@ -49,46 +51,39 @@ export class GitChangesWidget implements Widget { return '(+42,-10)'; } - const changes = this.getGitChanges(); + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const changes = this.getGitChanges(context); if (changes) return `(+${changes.insertions},-${changes.deletions})`; else return hideNoGit ? null : '(no git)'; } - private getGitChanges(): { insertions: number; deletions: number } | null { - try { - let totalInsertions = 0; - let totalDeletions = 0; + private getGitChanges(context: RenderContext): { insertions: number; deletions: number } | null { + let totalInsertions = 0; + let totalDeletions = 0; - const unstagedStat = execSync('git diff --shortstat', { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'] - }).trim(); + const unstagedStat = runGit('diff --shortstat', context) ?? ''; + const stagedStat = runGit('diff --cached --shortstat', context) ?? ''; - const stagedStat = execSync('git diff --cached --shortstat', { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'] - }).trim(); - - if (unstagedStat) { - const insertMatch = /(\d+) insertion/.exec(unstagedStat); - const deleteMatch = /(\d+) deletion/.exec(unstagedStat); - totalInsertions += insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0; - totalDeletions += deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0; - } - - if (stagedStat) { - const insertMatch = /(\d+) insertion/.exec(stagedStat); - const deleteMatch = /(\d+) deletion/.exec(stagedStat); - totalInsertions += insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0; - totalDeletions += deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0; - } - - return { insertions: totalInsertions, deletions: totalDeletions }; - } catch { - return null; + if (unstagedStat) { + const insertMatch = /(\d+) insertion/.exec(unstagedStat); + const deleteMatch = /(\d+) deletion/.exec(unstagedStat); + totalInsertions += insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0; + totalDeletions += deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0; } + + if (stagedStat) { + const insertMatch = /(\d+) insertion/.exec(stagedStat); + const deleteMatch = /(\d+) deletion/.exec(stagedStat); + totalInsertions += insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0; + totalDeletions += deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0; + } + + return { insertions: totalInsertions, deletions: totalDeletions }; } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/GitRootDir.ts b/src/widgets/GitRootDir.ts index 5c1c511..47a52c2 100644 --- a/src/widgets/GitRootDir.ts +++ b/src/widgets/GitRootDir.ts @@ -1,5 +1,3 @@ -import { execSync } from 'child_process'; - import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { @@ -8,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + isInsideGitWorkTree, + runGit +} from '../utils/git'; export class GitRootDirWidget implements Widget { getDefaultColor(): string { return 'cyan'; } @@ -49,7 +51,11 @@ export class GitRootDirWidget implements Widget { return 'my-repo'; } - const rootDir = this.getGitRootDir(); + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : 'no git'; + } + + const rootDir = this.getGitRootDir(context); if (rootDir) { return this.getRootDirName(rootDir); } @@ -57,16 +63,8 @@ export class GitRootDirWidget implements Widget { return hideNoGit ? null : 'no git'; } - private getGitRootDir(): string | null { - try { - const rootDir = execSync('git rev-parse --show-toplevel', { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'] - }).trim(); - return rootDir || null; - } catch { - return null; - } + private getGitRootDir(context: RenderContext): string | null { + return runGit('rev-parse --show-toplevel', context); } private getRootDirName(rootDir: string): string { diff --git a/src/widgets/GitWorktree.ts b/src/widgets/GitWorktree.ts index f614d54..6478e6f 100644 --- a/src/widgets/GitWorktree.ts +++ b/src/widgets/GitWorktree.ts @@ -1,5 +1,3 @@ -import { execSync } from 'child_process'; - import type { RenderContext } from '../types/RenderContext'; import type { CustomKeybind, @@ -7,6 +5,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + isInsideGitWorkTree, + runGit +} from '../utils/git'; export class GitWorktreeWidget implements Widget { getDefaultColor(): string { return 'blue'; } @@ -47,31 +49,39 @@ export class GitWorktreeWidget implements Widget { if (context.isPreview) return item.rawValue ? 'main' : '𖠰 main'; - const worktree = this.getGitWorktree(); + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '𖠰 no git'; + } + + const worktree = this.getGitWorktree(context); if (worktree) return item.rawValue ? worktree : `𖠰 ${worktree}`; return hideNoGit ? null : '𖠰 no git'; } - private getGitWorktree(): string | null { - try { - const worktreeDir = execSync('git rev-parse --git-dir', { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'] - }).trim(); - - // /some/path/.git or .git - if (worktreeDir.endsWith('/.git') || worktreeDir === '.git') - return 'main'; - - // /some/path/.git/worktrees/some-worktree or /some/path/.git/worktrees/some-dir/some-worktree - const [, worktree] = worktreeDir.split('.git/worktrees/'); - - return worktree ?? null; - } catch { + private getGitWorktree(context: RenderContext): string | null { + const worktreeDir = runGit('rev-parse --git-dir', context); + if (!worktreeDir) { return null; } + + const normalizedGitDir = worktreeDir.replace(/\\/g, '/'); + + // /some/path/.git or .git + if (normalizedGitDir.endsWith('/.git') || normalizedGitDir === '.git') + return 'main'; + + // /some/path/.git/worktrees/some-worktree or /some/path/.git/worktrees/some-dir/some-worktree + const marker = '.git/worktrees/'; + const markerIndex = normalizedGitDir.lastIndexOf(marker); + if (markerIndex === -1) { + return null; + } + + const worktree = normalizedGitDir.slice(markerIndex + marker.length); + + return worktree.length > 0 ? worktree : null; } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/__tests__/GitBranch.test.ts b/src/widgets/__tests__/GitBranch.test.ts new file mode 100644 index 0000000..554fad8 --- /dev/null +++ b/src/widgets/__tests__/GitBranch.test.ts @@ -0,0 +1,106 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { GitBranchWidget } from '../GitBranch'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + cwd?: string; + hideNoGit?: boolean; + isPreview?: boolean; + rawValue?: boolean; +} = {}) { + const widget = new GitBranchWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'git-branch', + type: 'git-branch', + rawValue: options.rawValue, + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitBranchWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('⎇ main'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('main'); + }); + + it('should render branch name', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('feature/worktree'); + + expect(render({ cwd: '/tmp/worktree' })).toBe('⎇ feature/worktree'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + expect(mockExecSync.mock.calls[1]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + }); + + it('should render raw branch value', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('feature/worktree'); + + expect(render({ rawValue: true })).toBe('feature/worktree'); + }); + + it('should render no git when probe returns false', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(render()).toBe('⎇ no git'); + }); + + it('should hide no git when configured', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(render({ hideNoGit: true })).toBeNull(); + }); + + it('should render no git when branch lookup is empty', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('⎇ no git'); + }); + + it('should render no git when command fails', () => { + mockExecSync.mockImplementation(() => { throw new Error('No git'); }); + + expect(render()).toBe('⎇ no git'); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/GitChanges.test.ts b/src/widgets/__tests__/GitChanges.test.ts new file mode 100644 index 0000000..d9161a6 --- /dev/null +++ b/src/widgets/__tests__/GitChanges.test.ts @@ -0,0 +1,100 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { GitChangesWidget } from '../GitChanges'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + cwd?: string; + hideNoGit?: boolean; + isPreview?: boolean; +} = {}) { + const widget = new GitChangesWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'git-changes', + type: 'git-changes', + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitChangesWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('(+42,-10)'); + }); + + it('should render combined staged and unstaged changes', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)'); + mockExecSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)'); + + expect(render({ cwd: '/tmp/worktree' })).toBe('(+5,-5)'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + expect(mockExecSync.mock.calls[1]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + expect(mockExecSync.mock.calls[2]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + }); + + it('should render zero counts when repo is clean', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('(+0,-0)'); + }); + + it('should render no git when probe returns false', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(render()).toBe('(no git)'); + }); + + it('should hide no git when configured', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(render({ hideNoGit: true })).toBeNull(); + }); + + it('should render no git when command fails', () => { + mockExecSync.mockImplementation(() => { throw new Error('No git'); }); + + expect(render()).toBe('(no git)'); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/GitRootDir.test.ts b/src/widgets/__tests__/GitRootDir.test.ts index 5266cb4..ab0eecf 100644 --- a/src/widgets/__tests__/GitRootDir.test.ts +++ b/src/widgets/__tests__/GitRootDir.test.ts @@ -15,13 +15,18 @@ import { GitRootDirWidget } from '../GitRootDir'; vi.mock('child_process', () => ({ execSync: vi.fn() })); const mockExecSync = execSync as unknown as { - mockReturnValue: (value: string) => void; + mock: { calls: unknown[][] }; mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; }; -function render(options: { isPreview?: boolean; hideNoGit?: boolean } = {}) { +function render(options: { cwd?: string; hideNoGit?: boolean; isPreview?: boolean } = {}) { const widget = new GitRootDirWidget(); - const context: RenderContext = { isPreview: options.isPreview }; + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; const item: WidgetItem = { id: 'git-root-dir', type: 'git-root-dir', @@ -41,29 +46,49 @@ describe('GitRootDirWidget', () => { }); it('should render root directory name', () => { - mockExecSync.mockReturnValue('/some/path/my-repo'); + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('/some/path/my-repo'); - expect(render()).toBe('my-repo'); + expect(render({ cwd: '/tmp/worktree' })).toBe('my-repo'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + expect(mockExecSync.mock.calls[1]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); }); it('should handle trailing separators', () => { - mockExecSync.mockReturnValue('/some/path/my-repo/'); + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('/some/path/my-repo/'); expect(render()).toBe('my-repo'); }); it('should render unix root path without returning empty output', () => { - mockExecSync.mockReturnValue('/'); + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('/'); expect(render()).toBe('/'); }); it('should render windows drive root without returning empty output', () => { - mockExecSync.mockReturnValue('C:/'); + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('C:/'); expect(render()).toBe('C:'); }); + it('should render no git when probe returns false', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(render()).toBe('no git'); + }); + it('should render no git when command fails', () => { mockExecSync.mockImplementation(() => { throw new Error('No git'); }); diff --git a/src/widgets/__tests__/GitWorktree.test.ts b/src/widgets/__tests__/GitWorktree.test.ts index 8b2be3d..30b5734 100644 --- a/src/widgets/__tests__/GitWorktree.test.ts +++ b/src/widgets/__tests__/GitWorktree.test.ts @@ -16,17 +16,28 @@ import { GitWorktreeWidget } from '../GitWorktree'; vi.mock('child_process', () => ({ execSync: vi.fn() })); const mockExecSync = execSync as unknown as { - mockReturnValue: (value: string) => void; + mock: { calls: unknown[][] }; mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; }; -function render(rawValue = false, isPreview = false) { +function render(options: { + cwd?: string; + hideNoGit?: boolean; + isPreview?: boolean; + rawValue?: boolean; +} = {}) { const widget = new GitWorktreeWidget(); - const context: RenderContext = { isPreview }; + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; const item: WidgetItem = { id: 'git-worktree', type: 'git-worktree', - rawValue + rawValue: options.rawValue, + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined }; return widget.render(item, context); @@ -38,45 +49,72 @@ describe('GitWorktreeWidget', () => { }); it('should render preview', () => { - const isPreview = true; - const rawValue = false; - - expect(render(rawValue, isPreview)).toBe('𖠰 main'); + expect(render({ isPreview: true })).toBe('𖠰 main'); }); it('should render preview with raw value', () => { - const isPreview = true; - const rawValue = true; - - expect(render(rawValue, isPreview)).toBe('main'); + expect(render({ isPreview: true, rawValue: true })).toBe('main'); }); it('should render with worktree', () => { - mockExecSync.mockReturnValue('/some/path/.git/worktrees/some-worktree'); + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('/some/path/.git/worktrees/some-worktree'); - expect(render()).toBe('𖠰 some-worktree'); + expect(render({ cwd: '/tmp/worktree' })).toBe('𖠰 some-worktree'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + expect(mockExecSync.mock.calls[1]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); }); it('should render with nested worktree', () => { - mockExecSync.mockReturnValue('/some/path/.git/worktrees/some-dir/some-worktree'); + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('/some/path/.git/worktrees/some-dir/some-worktree'); expect(render()).toBe('𖠰 some-dir/some-worktree'); }); it('should render with no worktree', () => { - mockExecSync.mockReturnValue('.git'); + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('.git'); expect(render()).toBe('𖠰 main'); }); + it('should handle windows git-dir paths', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('C:\\repo\\.git\\worktrees\\some-worktree'); + + expect(render()).toBe('𖠰 some-worktree'); + }); + + it('should render with no git when probe returns false', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(render()).toBe('𖠰 no git'); + }); + it('should render with no git', () => { mockExecSync.mockImplementation(() => { throw new Error('No git'); }); expect(render()).toBe('𖠰 no git'); }); + it('should hide no git when configured', () => { + mockExecSync.mockReturnValue('false\n'); + + expect(render({ hideNoGit: true })).toBeNull(); + }); + it('should render with invalid git dir', () => { - mockExecSync.mockReturnValue(''); + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); expect(render()).toBe('𖠰 no git'); });