feat: add git insertions and deletions widgets

Closes #115
This commit is contained in:
Matthew Breedlove committed 2026-03-03 00:22:13 -05:00
1 parent 0c6bf4b02c
commit 6edc1b4277
13 files changed
+434 -35

No files matched your search

+1 -1
View File
@@ -97,7 +97,7 @@ All widgets must implement:
**Available Widgets:**
- Model, Version, OutputStyle - Claude Code metadata display
- GitBranch, GitChanges, GitWorktree - Git repository status
- GitBranch, GitChanges, GitInsertions, GitDeletions, GitWorktree - Git repository status
- TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics
- ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models)
- BlockTimer, SessionClock, SessionCost - Time and cost tracking
+5 -1
View File
@@ -46,12 +46,14 @@
## 🆕 Recent Updates
### v2.1.0 - v2.1.3 - Usage widgets, links, and reliability fixes
### v2.1.0 - v2.1.4 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes
- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Reset Timer**, and **Context Bar** widgets.
- **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts.
- **🪟 Windows empty file bug fix (v2.1.1)** - Fixed a Windows issue that could create an empty `c:\dev\null` file.
- **🔗 New Link widget (v2.1.3)** - Added a new **Link** widget with clickable OSC8 rendering, preview parity, and raw mode support.
- **➕ New Git Insertions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted insertions (e.g., `+42`).
- **➖ New Git Deletions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted deletions (e.g., `-10`).
### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements
@@ -412,6 +414,8 @@ bun run example
- **Model Name** - Shows the current Claude model (e.g., "Claude 3.5 Sonnet")
- **Git Branch** - Displays current git branch name
- **Git Changes** - Shows uncommitted insertions/deletions (e.g., "+42,-10")
- **Git Insertions** - Shows uncommitted insertions only (e.g., "+42")
- **Git Deletions** - Shows uncommitted deletions only (e.g., "-10")
- **Git Root Dir** - Shows the git repository root directory name
- **Git Worktree** - Shows the name of the current git worktree
- **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ccstatusline",
"version": "2.1.3",
"version": "2.1.4",
"description": "A customizable status line formatter for Claude Code CLI",
"module": "src/ccstatusline.ts",
"type": "module",
+33
View File
@@ -9,6 +9,7 @@ import {
import type { RenderContext } from '../../types/RenderContext';
import {
getGitChangeCounts,
isInsideGitWorkTree,
resolveGitCwd,
runGit
@@ -20,6 +21,7 @@ const mockExecSync = execSync as unknown as {
mock: { calls: unknown[][] };
mockImplementation: (impl: () => never) => void;
mockReturnValue: (value: string) => void;
mockReturnValueOnce: (value: string) => void;
};
describe('git utils', () => {
@@ -134,4 +136,35 @@ describe('git utils', () => {
expect(isInsideGitWorkTree({})).toBe(false);
});
});
describe('getGitChangeCounts', () => {
it('sums staged and unstaged insertions/deletions', () => {
mockExecSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)');
mockExecSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)');
expect(getGitChangeCounts({})).toEqual({
insertions: 5,
deletions: 5
});
});
it('handles singular insertion/deletion forms', () => {
mockExecSync.mockReturnValueOnce('1 file changed, 1 insertion(+), 1 deletion(-)');
mockExecSync.mockReturnValueOnce('');
expect(getGitChangeCounts({})).toEqual({
insertions: 1,
deletions: 1
});
});
it('returns zero counts when git diff commands fail', () => {
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
expect(getGitChangeCounts({})).toEqual({
insertions: 0,
deletions: 0
});
});
});
});
+6
View File
@@ -28,6 +28,8 @@ describe('widget catalog', () => {
const model = catalog.find(entry => entry.type === 'model');
const separator = catalog.find(entry => entry.type === 'separator');
const link = catalog.find(entry => entry.type === 'link');
const gitInsertions = catalog.find(entry => entry.type === 'git-insertions');
const gitDeletions = catalog.find(entry => entry.type === 'git-deletions');
expect(model?.displayName).toBe('Model');
expect(model?.category).toBe('Core');
@@ -35,6 +37,10 @@ describe('widget catalog', () => {
expect(separator?.category).toBe('Layout');
expect(link?.displayName).toBe('Link');
expect(link?.category).toBe('Custom');
expect(gitInsertions?.displayName).toBe('Git Insertions');
expect(gitInsertions?.category).toBe('Git');
expect(gitDeletions?.displayName).toBe('Git Deletions');
expect(gitDeletions?.category).toBe('Git');
});
it('hides manual separator when default separator is configured', () => {
+27
View File
@@ -2,6 +2,11 @@ import { execSync } from 'child_process';
import type { RenderContext } from '../types/RenderContext';
export interface GitChangeCounts {
insertions: number;
deletions: number;
}
export function resolveGitCwd(context: RenderContext): string | undefined {
const candidates = [
context.data?.cwd,
@@ -35,4 +40,26 @@ export function runGit(command: string, context: RenderContext): string | null {
export function isInsideGitWorkTree(context: RenderContext): boolean {
return runGit('rev-parse --is-inside-work-tree', context) === 'true';
}
function parseDiffShortStat(stat: string): GitChangeCounts {
const insertMatch = /(\d+)\s+insertions?/.exec(stat);
const deleteMatch = /(\d+)\s+deletions?/.exec(stat);
return {
insertions: insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0,
deletions: deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0
};
}
export function getGitChangeCounts(context: RenderContext): GitChangeCounts {
const unstagedStat = runGit('diff --shortstat', context) ?? '';
const stagedStat = runGit('diff --cached --shortstat', context) ?? '';
const unstagedCounts = parseDiffShortStat(unstagedStat);
const stagedCounts = parseDiffShortStat(stagedStat);
return {
insertions: unstagedCounts.insertions + stagedCounts.insertions,
deletions: unstagedCounts.deletions + stagedCounts.deletions
};
}
+2
View File
@@ -11,6 +11,8 @@ const widgetRegistry = new Map<WidgetItemType, Widget>([
['output-style', new widgets.OutputStyleWidget()],
['git-branch', new widgets.GitBranchWidget()],
['git-changes', new widgets.GitChangesWidget()],
['git-insertions', new widgets.GitInsertionsWidget()],
['git-deletions', new widgets.GitDeletionsWidget()],
['git-root-dir', new widgets.GitRootDirWidget()],
['git-worktree', new widgets.GitWorktreeWidget()],
['current-working-dir', new widgets.CurrentWorkingDirWidget()],
+5 -32
View File
@@ -7,8 +7,8 @@ import type {
WidgetItem
} from '../types/Widget';
import {
isInsideGitWorkTree,
runGit
getGitChangeCounts,
isInsideGitWorkTree
} from '../utils/git';
export class GitChangesWidget implements Widget {
@@ -44,7 +44,7 @@ export class GitChangesWidget implements Widget {
return null;
}
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
const hideNoGit = item.metadata?.hideNoGit === 'true';
if (context.isPreview) {
@@ -55,35 +55,8 @@ export class GitChangesWidget implements Widget {
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(context: RenderContext): { insertions: number; deletions: number } | null {
let totalInsertions = 0;
let totalDeletions = 0;
const unstagedStat = runGit('diff --shortstat', context) ?? '';
const stagedStat = runGit('diff --cached --shortstat', context) ?? '';
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 };
const changes = getGitChangeCounts(context);
return `(+${changes.insertions},-${changes.deletions})`;
}
getCustomKeybinds(): CustomKeybind[] {
+70
View File
@@ -0,0 +1,70 @@
import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
CustomKeybind,
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import {
getGitChangeCounts,
isInsideGitWorkTree
} from '../utils/git';
export class GitDeletionsWidget implements Widget {
getDefaultColor(): string { return 'red'; }
getDescription(): string { return 'Shows git deletions count'; }
getDisplayName(): string { return 'Git Deletions'; }
getCategory(): string { return 'Git'; }
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
const hideNoGit = item.metadata?.hideNoGit === 'true';
const modifiers: string[] = [];
if (hideNoGit) {
modifiers.push('hide \'no git\'');
}
return {
displayText: this.getDisplayName(),
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
};
}
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === 'toggle-nogit') {
const currentState = item.metadata?.hideNoGit === 'true';
return {
...item,
metadata: {
...item.metadata,
hideNoGit: (!currentState).toString()
}
};
}
return null;
}
render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
const hideNoGit = item.metadata?.hideNoGit === 'true';
if (context.isPreview) {
return '-10';
}
if (!isInsideGitWorkTree(context)) {
return hideNoGit ? null : '(no git)';
}
const changes = getGitChangeCounts(context);
return `-${changes.deletions}`;
}
getCustomKeybinds(): CustomKeybind[] {
return [
{ key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' }
];
}
supportsRawValue(): boolean { return false; }
supportsColors(item: WidgetItem): boolean { return true; }
}
+70
View File
@@ -0,0 +1,70 @@
import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
CustomKeybind,
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import {
getGitChangeCounts,
isInsideGitWorkTree
} from '../utils/git';
export class GitInsertionsWidget implements Widget {
getDefaultColor(): string { return 'green'; }
getDescription(): string { return 'Shows git insertions count'; }
getDisplayName(): string { return 'Git Insertions'; }
getCategory(): string { return 'Git'; }
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
const hideNoGit = item.metadata?.hideNoGit === 'true';
const modifiers: string[] = [];
if (hideNoGit) {
modifiers.push('hide \'no git\'');
}
return {
displayText: this.getDisplayName(),
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
};
}
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === 'toggle-nogit') {
const currentState = item.metadata?.hideNoGit === 'true';
return {
...item,
metadata: {
...item.metadata,
hideNoGit: (!currentState).toString()
}
};
}
return null;
}
render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
const hideNoGit = item.metadata?.hideNoGit === 'true';
if (context.isPreview) {
return '+42';
}
if (!isInsideGitWorkTree(context)) {
return hideNoGit ? null : '(no git)';
}
const changes = getGitChangeCounts(context);
return `+${changes.insertions}`;
}
getCustomKeybinds(): CustomKeybind[] {
return [
{ key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' }
];
}
supportsRawValue(): boolean { return false; }
supportsColors(item: WidgetItem): boolean { return true; }
}
+106
View File
@@ -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 { GitDeletionsWidget } from '../GitDeletions';
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 GitDeletionsWidget();
const context: RenderContext = {
isPreview: options.isPreview,
data: options.cwd ? { cwd: options.cwd } : undefined
};
const item: WidgetItem = {
id: 'git-deletions',
type: 'git-deletions',
metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined
};
return widget.render(item, context, DEFAULT_SETTINGS);
}
describe('GitDeletionsWidget', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render preview', () => {
expect(render({ isPreview: true })).toBe('-10');
});
it('should render combined staged and unstaged deletions', () => {
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');
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 count when repo is clean', () => {
mockExecSync.mockReturnValueOnce('true\n');
mockExecSync.mockReturnValueOnce('');
mockExecSync.mockReturnValueOnce('');
expect(render()).toBe('-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)');
});
it('should disable raw value support', () => {
const widget = new GitDeletionsWidget();
expect(widget.supportsRawValue()).toBe(false);
});
});
+106
View File
@@ -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 { GitInsertionsWidget } from '../GitInsertions';
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 GitInsertionsWidget();
const context: RenderContext = {
isPreview: options.isPreview,
data: options.cwd ? { cwd: options.cwd } : undefined
};
const item: WidgetItem = {
id: 'git-insertions',
type: 'git-insertions',
metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined
};
return widget.render(item, context, DEFAULT_SETTINGS);
}
describe('GitInsertionsWidget', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render preview', () => {
expect(render({ isPreview: true })).toBe('+42');
});
it('should render combined staged and unstaged insertions', () => {
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');
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 count when repo is clean', () => {
mockExecSync.mockReturnValueOnce('true\n');
mockExecSync.mockReturnValueOnce('');
mockExecSync.mockReturnValueOnce('');
expect(render()).toBe('+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)');
});
it('should disable raw value support', () => {
const widget = new GitInsertionsWidget();
expect(widget.supportsRawValue()).toBe(false);
});
});
+2
View File
@@ -2,6 +2,8 @@ export { ModelWidget } from './Model';
export { OutputStyleWidget } from './OutputStyle';
export { GitBranchWidget } from './GitBranch';
export { GitChangesWidget } from './GitChanges';
export { GitInsertionsWidget } from './GitInsertions';
export { GitDeletionsWidget } from './GitDeletions';
export { GitRootDirWidget } from './GitRootDir';
export { GitWorktreeWidget } from './GitWorktree';
export { TokensInputWidget } from './TokensInput';