From 9b3940df7d15e1eed4b7b15f678de80bb7aa93f7 Mon Sep 17 00:00:00 2001 From: Tejas DC Date: Sun, 8 Mar 2026 06:01:31 -0400 Subject: [PATCH] feat: add 7 git status widgets (staged, unstaged, untracked, ahead/behind, conflicts, clean status, SHA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add granular git repository status widgets addressing issue #20: - GitStagedFiles: staged file count (S:N format) - GitUnstagedFiles: unstaged file count (M:N format) - GitUntrackedFiles: untracked file count (A:N format) - GitAheadBehind: upstream ahead/behind tracking (↑N ↓M format) - GitMergeConflicts: merge conflict count (⚠N, hidden when 0) - GitCleanStatus: clean/dirty indicator (✓/✗) - GitSha: short commit SHA All widgets follow existing patterns: Widget interface, git-no-git shared helpers, hideNoGit support, rawValue support, and cross-platform git commands via runGit(). Includes shared getGitFileStatusCounts() utility for the file count widgets. 21 new tests across 7 test files + shared behavior test entries. Closes #20 --- src/utils/__tests__/git.test.ts | 37 +++++ src/utils/git.ts | 26 ++++ src/utils/widget-manifest.ts | 7 + src/widgets/GitAheadBehind.ts | 92 ++++++++++++ src/widgets/GitCleanStatus.ts | 66 +++++++++ src/widgets/GitMergeConflicts.ts | 71 ++++++++++ src/widgets/GitSha.ts | 66 +++++++++ src/widgets/GitStagedFiles.ts | 58 ++++++++ src/widgets/GitUnstagedFiles.ts | 58 ++++++++ src/widgets/GitUntrackedFiles.ts | 58 ++++++++ src/widgets/__tests__/GitAheadBehind.test.ts | 134 ++++++++++++++++++ src/widgets/__tests__/GitCleanStatus.test.ts | 113 +++++++++++++++ .../__tests__/GitMergeConflicts.test.ts | 106 ++++++++++++++ src/widgets/__tests__/GitSha.test.ts | 99 +++++++++++++ src/widgets/__tests__/GitStagedFiles.test.ts | 122 ++++++++++++++++ .../__tests__/GitUnstagedFiles.test.ts | 122 ++++++++++++++++ .../__tests__/GitUntrackedFiles.test.ts | 122 ++++++++++++++++ .../__tests__/GitWidgetSharedBehavior.test.ts | 14 ++ src/widgets/index.ts | 7 + 19 files changed, 1378 insertions(+) create mode 100644 src/widgets/GitAheadBehind.ts create mode 100644 src/widgets/GitCleanStatus.ts create mode 100644 src/widgets/GitMergeConflicts.ts create mode 100644 src/widgets/GitSha.ts create mode 100644 src/widgets/GitStagedFiles.ts create mode 100644 src/widgets/GitUnstagedFiles.ts create mode 100644 src/widgets/GitUntrackedFiles.ts create mode 100644 src/widgets/__tests__/GitAheadBehind.test.ts create mode 100644 src/widgets/__tests__/GitCleanStatus.test.ts create mode 100644 src/widgets/__tests__/GitMergeConflicts.test.ts create mode 100644 src/widgets/__tests__/GitSha.test.ts create mode 100644 src/widgets/__tests__/GitStagedFiles.test.ts create mode 100644 src/widgets/__tests__/GitUnstagedFiles.test.ts create mode 100644 src/widgets/__tests__/GitUntrackedFiles.test.ts diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 74ddb314..76984cdf 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { getGitChangeCounts, + getGitFileStatusCounts, isInsideGitWorkTree, resolveGitCwd, runGit @@ -167,4 +168,40 @@ describe('git utils', () => { }); }); }); + + describe('getGitFileStatusCounts', () => { + it('counts staged, unstaged, and untracked files', () => { + mockExecSync.mockReturnValueOnce('staged-a\nstaged-b\n'); + mockExecSync.mockReturnValueOnce('unstaged-a'); + mockExecSync.mockReturnValueOnce('new-a\nnew-b\nnew-c\n'); + + expect(getGitFileStatusCounts({})).toEqual({ + staged: 2, + unstaged: 1, + untracked: 3 + }); + }); + + it('returns zero counts when there are no matching files', () => { + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + + expect(getGitFileStatusCounts({})).toEqual({ + staged: 0, + unstaged: 0, + untracked: 0 + }); + }); + + it('returns zero counts when git commands fail', () => { + mockExecSync.mockImplementation(() => { throw new Error('git failed'); }); + + expect(getGitFileStatusCounts({})).toEqual({ + staged: 0, + unstaged: 0, + untracked: 0 + }); + }); + }); }); \ No newline at end of file diff --git a/src/utils/git.ts b/src/utils/git.ts index e73b9999..e1c58f93 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -7,6 +7,12 @@ export interface GitChangeCounts { deletions: number; } +export interface GitFileStatusCounts { + staged: number; + unstaged: number; + untracked: number; +} + export function resolveGitCwd(context: RenderContext): string | undefined { const candidates = [ context.data?.cwd, @@ -62,4 +68,24 @@ export function getGitChangeCounts(context: RenderContext): GitChangeCounts { insertions: unstagedCounts.insertions + stagedCounts.insertions, deletions: unstagedCounts.deletions + stagedCounts.deletions }; +} + +function countOutputLines(output: string | null): number { + if (!output) { + return 0; + } + + return output.split('\n').filter(line => line.length > 0).length; +} + +export function getGitFileStatusCounts(context: RenderContext): GitFileStatusCounts { + const staged = countOutputLines(runGit('diff --cached --name-only', context)); + const unstaged = countOutputLines(runGit('diff --name-only', context)); + const untracked = countOutputLines(runGit('ls-files --others --exclude-standard', context)); + + return { + staged, + unstaged, + untracked + }; } \ No newline at end of file diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index ef3011b1..c6d7548e 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -23,6 +23,13 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'git-changes', create: () => new widgets.GitChangesWidget() }, { type: 'git-insertions', create: () => new widgets.GitInsertionsWidget() }, { type: 'git-deletions', create: () => new widgets.GitDeletionsWidget() }, + { type: 'git-staged-files', create: () => new widgets.GitStagedFilesWidget() }, + { type: 'git-unstaged-files', create: () => new widgets.GitUnstagedFilesWidget() }, + { type: 'git-untracked-files', create: () => new widgets.GitUntrackedFilesWidget() }, + { type: 'git-ahead-behind', create: () => new widgets.GitAheadBehindWidget() }, + { type: 'git-merge-conflicts', create: () => new widgets.GitMergeConflictsWidget() }, + { type: 'git-clean-status', create: () => new widgets.GitCleanStatusWidget() }, + { type: 'git-sha', create: () => new widgets.GitShaWidget() }, { type: 'git-root-dir', create: () => new widgets.GitRootDirWidget() }, { type: 'git-worktree', create: () => new widgets.GitWorktreeWidget() }, { type: 'current-working-dir', create: () => new widgets.CurrentWorkingDirWidget() }, diff --git a/src/widgets/GitAheadBehind.ts b/src/widgets/GitAheadBehind.ts new file mode 100644 index 00000000..e59e5efc --- /dev/null +++ b/src/widgets/GitAheadBehind.ts @@ -0,0 +1,92 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideGitWorkTree, + runGit +} from '../utils/git'; + +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +interface AheadBehindCounts { + ahead: number; + behind: number; +} + +export class GitAheadBehindWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows commits ahead/behind upstream'; } + getDisplayName(): string { return 'Git Ahead Behind'; } + getCategory(): string { return 'Git'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoGitModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? '2 1' : '↑2 ↓1'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const counts = this.getAheadBehindCounts(context); + if (!counts) { + return hideNoGit ? null : '(no upstream)'; + } + + return item.rawValue + ? `${counts.ahead} ${counts.behind}` + : `↑${counts.ahead} ↓${counts.behind}`; + } + + private getAheadBehindCounts(context: RenderContext): AheadBehindCounts | null { + const output = runGit('rev-list --left-right --count HEAD...@{upstream}', context); + if (!output) { + return null; + } + + const [aheadRaw, behindRaw] = output.split(/\s+/); + if (!aheadRaw || !behindRaw) { + return null; + } + + const ahead = parseInt(aheadRaw, 10); + const behind = parseInt(behindRaw, 10); + if (Number.isNaN(ahead) || Number.isNaN(behind)) { + return null; + } + + return { + ahead, + behind + }; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitCleanStatus.ts b/src/widgets/GitCleanStatus.ts new file mode 100644 index 00000000..ba6e178f --- /dev/null +++ b/src/widgets/GitCleanStatus.ts @@ -0,0 +1,66 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideGitWorkTree, + runGit +} from '../utils/git'; + +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitCleanStatusWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows whether git working tree is clean or dirty'; } + getDisplayName(): string { return 'Git Clean Status'; } + getCategory(): string { return 'Git'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoGitModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? 'clean' : '✓'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const clean = this.isClean(context); + if (item.rawValue) { + return clean ? 'clean' : 'dirty'; + } + + return clean ? '✓' : '✗'; + } + + private isClean(context: RenderContext): boolean { + return runGit('status --porcelain', context) === null; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitMergeConflicts.ts b/src/widgets/GitMergeConflicts.ts new file mode 100644 index 00000000..e22f8710 --- /dev/null +++ b/src/widgets/GitMergeConflicts.ts @@ -0,0 +1,71 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideGitWorkTree, + runGit +} from '../utils/git'; + +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitMergeConflictsWidget implements Widget { + getDefaultColor(): string { return 'red'; } + getDescription(): string { return 'Shows git merge conflicts count'; } + getDisplayName(): string { return 'Git Merge Conflicts'; } + getCategory(): string { return 'Git'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoGitModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? '1' : '⚠1'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const conflicts = this.getConflictCount(context); + if (conflicts === 0) { + return null; + } + + return item.rawValue ? `${conflicts}` : `⚠${conflicts}`; + } + + private getConflictCount(context: RenderContext): number { + const output = runGit('diff --name-only --diff-filter=U', context); + if (!output) { + return 0; + } + + return output.split('\n').filter(line => line.length > 0).length; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitSha.ts b/src/widgets/GitSha.ts new file mode 100644 index 00000000..f498cba5 --- /dev/null +++ b/src/widgets/GitSha.ts @@ -0,0 +1,66 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideGitWorkTree, + runGit +} from '../utils/git'; + +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitShaWidget implements Widget { + getDefaultColor(): string { return 'brightBlack'; } + getDescription(): string { return 'Shows current git commit short SHA'; } + getDisplayName(): string { return 'Git SHA'; } + getCategory(): string { return 'Git'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoGitModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return 'a3f2c1d'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const sha = this.getGitSha(context); + if (sha) { + return sha; + } + + return hideNoGit ? null : '(no git)'; + } + + private getGitSha(context: RenderContext): string | null { + return runGit('rev-parse --short HEAD', context); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitStagedFiles.ts b/src/widgets/GitStagedFiles.ts new file mode 100644 index 00000000..fd121bb3 --- /dev/null +++ b/src/widgets/GitStagedFiles.ts @@ -0,0 +1,58 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitFileStatusCounts, + isInsideGitWorkTree +} from '../utils/git'; + +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitStagedFilesWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows git staged files count'; } + getDisplayName(): string { return 'Git Staged Files'; } + getCategory(): string { return 'Git'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoGitModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? '3' : 'S:3'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const counts = getGitFileStatusCounts(context); + return item.rawValue ? `${counts.staged}` : `S:${counts.staged}`; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUnstagedFiles.ts b/src/widgets/GitUnstagedFiles.ts new file mode 100644 index 00000000..0e9cb83b --- /dev/null +++ b/src/widgets/GitUnstagedFiles.ts @@ -0,0 +1,58 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitFileStatusCounts, + isInsideGitWorkTree +} from '../utils/git'; + +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitUnstagedFilesWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows git unstaged files count'; } + getDisplayName(): string { return 'Git Unstaged Files'; } + getCategory(): string { return 'Git'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoGitModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? '2' : 'M:2'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const counts = getGitFileStatusCounts(context); + return item.rawValue ? `${counts.unstaged}` : `M:${counts.unstaged}`; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUntrackedFiles.ts b/src/widgets/GitUntrackedFiles.ts new file mode 100644 index 00000000..fe663223 --- /dev/null +++ b/src/widgets/GitUntrackedFiles.ts @@ -0,0 +1,58 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitFileStatusCounts, + isInsideGitWorkTree +} from '../utils/git'; + +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitUntrackedFilesWidget implements Widget { + getDefaultColor(): string { return 'red'; } + getDescription(): string { return 'Shows git untracked files count'; } + getDisplayName(): string { return 'Git Untracked Files'; } + getCategory(): string { return 'Git'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoGitModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? '1' : 'A:1'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const counts = getGitFileStatusCounts(context); + return item.rawValue ? `${counts.untracked}` : `A:${counts.untracked}`; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/GitAheadBehind.test.ts b/src/widgets/__tests__/GitAheadBehind.test.ts new file mode 100644 index 00000000..b43f1535 --- /dev/null +++ b/src/widgets/__tests__/GitAheadBehind.test.ts @@ -0,0 +1,134 @@ +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 { GitAheadBehindWidget } from '../GitAheadBehind'; + +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 GitAheadBehindWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'git-ahead-behind', + type: 'git-ahead-behind', + rawValue: options.rawValue, + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitAheadBehindWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('↑2 ↓1'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('2 1'); + }); + + it('should render ahead/behind counts', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('2\t1'); + + expect(render({ cwd: '/tmp/worktree' })).toBe('↑2 ↓1'); + 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 ahead/behind counts', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('2 1'); + + expect(render({ rawValue: true })).toBe('2 1'); + }); + + it('should render no upstream when upstream lookup is empty', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('(no upstream)'); + }); + + it('should hide no upstream when configured', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render({ hideNoGit: true })).toBeNull(); + }); + + it('should render no upstream when upstream counts are invalid', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('invalid'); + + expect(render()).toBe('(no upstream)'); + }); + + 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)'); + }); + + it('should render no upstream when git is available but upstream lookup throws', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockImplementation(() => { throw new Error('fatal: no upstream configured'); }); + + expect(render()).toBe('(no upstream)'); + }); + + it('should hide no upstream when git is available but upstream lookup throws and hideNoGit configured', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockImplementation(() => { throw new Error('fatal: no upstream configured'); }); + + expect(render({ hideNoGit: true })).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/GitCleanStatus.test.ts b/src/widgets/__tests__/GitCleanStatus.test.ts new file mode 100644 index 00000000..23108cb1 --- /dev/null +++ b/src/widgets/__tests__/GitCleanStatus.test.ts @@ -0,0 +1,113 @@ +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 { GitCleanStatusWidget } from '../GitCleanStatus'; + +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 GitCleanStatusWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'git-clean-status', + type: 'git-clean-status', + rawValue: options.rawValue, + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitCleanStatusWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('✓'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('clean'); + }); + + it('should render clean status', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render({ cwd: '/tmp/worktree' })).toBe('✓'); + 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 dirty status', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(' M file.ts\n'); + + expect(render()).toBe('✗'); + }); + + it('should render raw clean status', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render({ rawValue: true })).toBe('clean'); + }); + + it('should render raw dirty status', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(' M file.ts\n'); + + expect(render({ rawValue: true })).toBe('dirty'); + }); + + 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__/GitMergeConflicts.test.ts b/src/widgets/__tests__/GitMergeConflicts.test.ts new file mode 100644 index 00000000..76cc8efc --- /dev/null +++ b/src/widgets/__tests__/GitMergeConflicts.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 { GitMergeConflictsWidget } from '../GitMergeConflicts'; + +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 GitMergeConflictsWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'git-merge-conflicts', + type: 'git-merge-conflicts', + rawValue: options.rawValue, + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitMergeConflictsWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('⚠1'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('1'); + }); + + it('should render merge conflicts count', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('a.ts\nb.ts\n'); + + expect(render({ cwd: '/tmp/worktree' })).toBe('⚠2'); + 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 merge conflicts count', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('a.ts\nb.ts\n'); + + expect(render({ rawValue: true })).toBe('2'); + }); + + it('should hide widget when there are no merge conflicts', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBeNull(); + }); + + 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__/GitSha.test.ts b/src/widgets/__tests__/GitSha.test.ts new file mode 100644 index 00000000..65488c66 --- /dev/null +++ b/src/widgets/__tests__/GitSha.test.ts @@ -0,0 +1,99 @@ +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 { GitShaWidget } from '../GitSha'; + +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 GitShaWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'git-sha', + type: 'git-sha', + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitShaWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('a3f2c1d'); + }); + + it('should render short SHA', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('a3f2c1d\n'); + + expect(render({ cwd: '/tmp/worktree' })).toBe('a3f2c1d'); + 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 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 SHA 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)'); + }); + + it('should disable raw value support', () => { + const widget = new GitShaWidget(); + + expect(widget.supportsRawValue()).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/GitStagedFiles.test.ts b/src/widgets/__tests__/GitStagedFiles.test.ts new file mode 100644 index 00000000..1be11cba --- /dev/null +++ b/src/widgets/__tests__/GitStagedFiles.test.ts @@ -0,0 +1,122 @@ +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 { GitStagedFilesWidget } from '../GitStagedFiles'; + +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 GitStagedFilesWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'git-staged-files', + type: 'git-staged-files', + rawValue: options.rawValue, + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitStagedFilesWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('S:3'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('3'); + }); + + it('should render staged files count', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('a.ts\nb.ts\nc.ts\n'); + mockExecSync.mockReturnValueOnce('m.ts\n'); + mockExecSync.mockReturnValueOnce('u.ts\n'); + + expect(render({ cwd: '/tmp/worktree' })).toBe('S:3'); + 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' + }); + expect(mockExecSync.mock.calls[3]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + }); + + it('should render raw staged files count', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('a.ts\nb.ts\nc.ts\n'); + mockExecSync.mockReturnValueOnce('m.ts\n'); + mockExecSync.mockReturnValueOnce('u.ts\n'); + + expect(render({ rawValue: true })).toBe('3'); + }); + + it('should render zero count when repo is clean', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('S: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__/GitUnstagedFiles.test.ts b/src/widgets/__tests__/GitUnstagedFiles.test.ts new file mode 100644 index 00000000..01c1e64e --- /dev/null +++ b/src/widgets/__tests__/GitUnstagedFiles.test.ts @@ -0,0 +1,122 @@ +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 { GitUnstagedFilesWidget } from '../GitUnstagedFiles'; + +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 GitUnstagedFilesWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'git-unstaged-files', + type: 'git-unstaged-files', + rawValue: options.rawValue, + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitUnstagedFilesWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('M:2'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('2'); + }); + + it('should render unstaged files count', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('a.ts\n'); + mockExecSync.mockReturnValueOnce('b.ts\nc.ts\n'); + mockExecSync.mockReturnValueOnce('u.ts\n'); + + expect(render({ cwd: '/tmp/worktree' })).toBe('M:2'); + 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' + }); + expect(mockExecSync.mock.calls[3]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + }); + + it('should render raw unstaged files count', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('a.ts\n'); + mockExecSync.mockReturnValueOnce('b.ts\nc.ts\n'); + mockExecSync.mockReturnValueOnce('u.ts\n'); + + expect(render({ rawValue: true })).toBe('2'); + }); + + it('should render zero count when repo is clean', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('M: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__/GitUntrackedFiles.test.ts b/src/widgets/__tests__/GitUntrackedFiles.test.ts new file mode 100644 index 00000000..5d184293 --- /dev/null +++ b/src/widgets/__tests__/GitUntrackedFiles.test.ts @@ -0,0 +1,122 @@ +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 { GitUntrackedFilesWidget } from '../GitUntrackedFiles'; + +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 GitUntrackedFilesWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'git-untracked-files', + type: 'git-untracked-files', + rawValue: options.rawValue, + metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitUntrackedFilesWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('A:1'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('1'); + }); + + it('should render untracked files count', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('a.ts\n'); + mockExecSync.mockReturnValueOnce('m.ts\n'); + mockExecSync.mockReturnValueOnce('u.ts\nv.ts\n'); + + expect(render({ cwd: '/tmp/worktree' })).toBe('A:2'); + 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' + }); + expect(mockExecSync.mock.calls[3]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/worktree' + }); + }); + + it('should render raw untracked files count', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('a.ts\n'); + mockExecSync.mockReturnValueOnce('m.ts\n'); + mockExecSync.mockReturnValueOnce('u.ts\nv.ts\n'); + + expect(render({ rawValue: true })).toBe('2'); + }); + + it('should render zero count when repo is clean', () => { + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('A: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__/GitWidgetSharedBehavior.test.ts b/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts index b90ef0cb..653f5580 100644 --- a/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts +++ b/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts @@ -9,11 +9,18 @@ import type { Widget, WidgetItem } from '../../types'; +import { GitAheadBehindWidget } from '../GitAheadBehind'; import { GitBranchWidget } from '../GitBranch'; import { GitChangesWidget } from '../GitChanges'; +import { GitCleanStatusWidget } from '../GitCleanStatus'; import { GitDeletionsWidget } from '../GitDeletions'; import { GitInsertionsWidget } from '../GitInsertions'; +import { GitMergeConflictsWidget } from '../GitMergeConflicts'; import { GitRootDirWidget } from '../GitRootDir'; +import { GitShaWidget } from '../GitSha'; +import { GitStagedFilesWidget } from '../GitStagedFiles'; +import { GitUnstagedFilesWidget } from '../GitUnstagedFiles'; +import { GitUntrackedFilesWidget } from '../GitUntrackedFiles'; import { GitWorktreeWidget } from '../GitWorktree'; type GitWidget = Widget & { @@ -26,6 +33,13 @@ const cases: { name: string; itemType: string; widget: GitWidget }[] = [ { name: 'GitChangesWidget', itemType: 'git-changes', widget: new GitChangesWidget() }, { name: 'GitInsertionsWidget', itemType: 'git-insertions', widget: new GitInsertionsWidget() }, { name: 'GitDeletionsWidget', itemType: 'git-deletions', widget: new GitDeletionsWidget() }, + { name: 'GitStagedFilesWidget', itemType: 'git-staged-files', widget: new GitStagedFilesWidget() }, + { name: 'GitUnstagedFilesWidget', itemType: 'git-unstaged-files', widget: new GitUnstagedFilesWidget() }, + { name: 'GitUntrackedFilesWidget', itemType: 'git-untracked-files', widget: new GitUntrackedFilesWidget() }, + { name: 'GitAheadBehindWidget', itemType: 'git-ahead-behind', widget: new GitAheadBehindWidget() }, + { name: 'GitMergeConflictsWidget', itemType: 'git-merge-conflicts', widget: new GitMergeConflictsWidget() }, + { name: 'GitCleanStatusWidget', itemType: 'git-clean-status', widget: new GitCleanStatusWidget() }, + { name: 'GitShaWidget', itemType: 'git-sha', widget: new GitShaWidget() }, { name: 'GitRootDirWidget', itemType: 'git-root-dir', widget: new GitRootDirWidget() }, { name: 'GitWorktreeWidget', itemType: 'git-worktree', widget: new GitWorktreeWidget() } ]; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 13fc9329..2b93f177 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -4,6 +4,13 @@ export { GitBranchWidget } from './GitBranch'; export { GitChangesWidget } from './GitChanges'; export { GitInsertionsWidget } from './GitInsertions'; export { GitDeletionsWidget } from './GitDeletions'; +export { GitStagedFilesWidget } from './GitStagedFiles'; +export { GitUnstagedFilesWidget } from './GitUnstagedFiles'; +export { GitUntrackedFilesWidget } from './GitUntrackedFiles'; +export { GitAheadBehindWidget } from './GitAheadBehind'; +export { GitMergeConflictsWidget } from './GitMergeConflicts'; +export { GitCleanStatusWidget } from './GitCleanStatus'; +export { GitShaWidget } from './GitSha'; export { GitRootDirWidget } from './GitRootDir'; export { GitWorktreeWidget } from './GitWorktree'; export { TokensInputWidget } from './TokensInput';