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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/utils/__tests__/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import type { RenderContext } from '../../types/RenderContext';
import {
getGitChangeCounts,
getGitFileStatusCounts,
isInsideGitWorkTree,
resolveGitCwd,
runGit
Expand Down Expand Up @@ -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
});
});
});
});
26 changes: 26 additions & 0 deletions src/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
};
}
7 changes: 7 additions & 0 deletions src/utils/widget-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down
92 changes: 92 additions & 0 deletions src/widgets/GitAheadBehind.ts
Original file line number Diff line number Diff line change
@@ -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; }
}
66 changes: 66 additions & 0 deletions src/widgets/GitCleanStatus.ts
Original file line number Diff line number Diff line change
@@ -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; }
}
71 changes: 71 additions & 0 deletions src/widgets/GitMergeConflicts.ts
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading