mirror of
https://github.com/jarrodwatts/claude-hud.git
synced 2026-05-21 15:52:37 +00:00
feat(git): Add file stats display (Starship-compatible format) (#71)
Add showFileStats option to display file change counts in Starship-compatible format: - ! = Modified files - + = Added/staged files - ✘ = Deleted files - ? = Untracked files Example: git:(main* !2 +1 ?3) Changes: - src/git.ts: Add FileStats interface and parseFileStats() function - src/config.ts: Add showFileStats config option (default: false) - src/render/session-line.ts: Add file stats rendering logic - commands/configure.md: Add "File stats" option to Git Style selection - tests: Add unit tests for fileStats parsing and rendering Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -105,6 +105,7 @@ Info items (Counts, Tokens, Usage, Duration) can be turned off via "Reset to Min
|
||||
- "Branch only" - git:(main)
|
||||
- "Branch + dirty" - git:(main*) shows uncommitted changes
|
||||
- "Full details" - git:(main* ↑2 ↓1) includes ahead/behind
|
||||
- "File stats" - git:(main* !2 +1 ?3) Starship-compatible format
|
||||
|
||||
**Skip Q3 if Git is OFF** - show only 3 questions total, or replace with placeholder.
|
||||
|
||||
@@ -143,9 +144,10 @@ Info items (Counts, Tokens, Usage, Duration) can be turned off via "Reset to Min
|
||||
|
||||
| Option | Config |
|
||||
|--------|--------|
|
||||
| Branch only | `gitStatus: { enabled: true, showDirty: false, showAheadBehind: false }` |
|
||||
| Branch + dirty | `gitStatus: { enabled: true, showDirty: true, showAheadBehind: false }` |
|
||||
| Full details | `gitStatus: { enabled: true, showDirty: true, showAheadBehind: true }` |
|
||||
| Branch only | `gitStatus: { enabled: true, showDirty: false, showAheadBehind: false, showFileStats: false }` |
|
||||
| Branch + dirty | `gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: false }` |
|
||||
| Full details | `gitStatus: { enabled: true, showDirty: true, showAheadBehind: true, showFileStats: false }` |
|
||||
| File stats | `gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: true }` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface HudConfig {
|
||||
enabled: boolean;
|
||||
showDirty: boolean;
|
||||
showAheadBehind: boolean;
|
||||
showFileStats: boolean;
|
||||
};
|
||||
display: {
|
||||
showModel: boolean;
|
||||
@@ -35,6 +36,7 @@ export const DEFAULT_CONFIG: HudConfig = {
|
||||
enabled: true,
|
||||
showDirty: true,
|
||||
showAheadBehind: false,
|
||||
showFileStats: false,
|
||||
},
|
||||
display: {
|
||||
showModel: true,
|
||||
@@ -86,6 +88,9 @@ function mergeConfig(userConfig: Partial<HudConfig>): HudConfig {
|
||||
showAheadBehind: typeof userConfig.gitStatus?.showAheadBehind === 'boolean'
|
||||
? userConfig.gitStatus.showAheadBehind
|
||||
: DEFAULT_CONFIG.gitStatus.showAheadBehind,
|
||||
showFileStats: typeof userConfig.gitStatus?.showFileStats === 'boolean'
|
||||
? userConfig.gitStatus.showFileStats
|
||||
: DEFAULT_CONFIG.gitStatus.showFileStats,
|
||||
};
|
||||
|
||||
const display = {
|
||||
|
||||
48
src/git.ts
48
src/git.ts
@@ -3,11 +3,19 @@ import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface FileStats {
|
||||
modified: number;
|
||||
added: number;
|
||||
deleted: number;
|
||||
untracked: number;
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
branch: string;
|
||||
isDirty: boolean;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
fileStats?: FileStats;
|
||||
}
|
||||
|
||||
export async function getGitBranch(cwd?: string): Promise<string | null> {
|
||||
@@ -38,15 +46,20 @@ export async function getGitStatus(cwd?: string): Promise<GitStatus | null> {
|
||||
const branch = branchOut.trim();
|
||||
if (!branch) return null;
|
||||
|
||||
// Check for dirty state (uncommitted changes)
|
||||
// Check for dirty state and parse file stats
|
||||
let isDirty = false;
|
||||
let fileStats: FileStats | undefined;
|
||||
try {
|
||||
const { stdout: statusOut } = await execFileAsync(
|
||||
'git',
|
||||
['--no-optional-locks', 'status', '--porcelain'],
|
||||
{ cwd, timeout: 1000, encoding: 'utf8' }
|
||||
);
|
||||
isDirty = statusOut.trim().length > 0;
|
||||
const trimmed = statusOut.trim();
|
||||
isDirty = trimmed.length > 0;
|
||||
if (isDirty) {
|
||||
fileStats = parseFileStats(trimmed);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, assume clean
|
||||
}
|
||||
@@ -69,8 +82,37 @@ export async function getGitStatus(cwd?: string): Promise<GitStatus | null> {
|
||||
// No upstream or error, keep 0/0
|
||||
}
|
||||
|
||||
return { branch, isDirty, ahead, behind };
|
||||
return { branch, isDirty, ahead, behind, fileStats };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse git status --porcelain output and count file stats (Starship-compatible format)
|
||||
* Status codes: M=modified, A=added, D=deleted, ??=untracked
|
||||
*/
|
||||
function parseFileStats(porcelainOutput: string): FileStats {
|
||||
const stats: FileStats = { modified: 0, added: 0, deleted: 0, untracked: 0 };
|
||||
const lines = porcelainOutput.split('\n').filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length < 2) continue;
|
||||
|
||||
const index = line[0]; // staged status
|
||||
const worktree = line[1]; // unstaged status
|
||||
|
||||
if (line.startsWith('??')) {
|
||||
stats.untracked++;
|
||||
} else if (index === 'A') {
|
||||
stats.added++;
|
||||
} else if (index === 'D' || worktree === 'D') {
|
||||
stats.deleted++;
|
||||
} else if (index === 'M' || worktree === 'M' || index === 'R' || index === 'C') {
|
||||
// M=modified, R=renamed (counts as modified), C=copied (counts as modified)
|
||||
stats.modified++;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,19 @@ export function renderSessionLine(ctx: RenderContext): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Show file stats in Starship-compatible format (!modified +added ✘deleted ?untracked)
|
||||
if (gitConfig?.showFileStats && ctx.gitStatus.fileStats) {
|
||||
const { modified, added, deleted, untracked } = ctx.gitStatus.fileStats;
|
||||
const statParts: string[] = [];
|
||||
if (modified > 0) statParts.push(`!${modified}`);
|
||||
if (added > 0) statParts.push(`+${added}`);
|
||||
if (deleted > 0) statParts.push(`✘${deleted}`);
|
||||
if (untracked > 0) statParts.push(`?${untracked}`);
|
||||
if (statParts.length > 0) {
|
||||
gitParts.push(` ${statParts.join(' ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
gitPart = ` ${magenta('git:(')}${cyan(gitParts.join(''))}${magenta(')')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,3 +103,104 @@ test('getGitStatus detects dirty state', async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// fileStats tests
|
||||
test('getGitStatus returns undefined fileStats for clean repo', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result?.fileStats, undefined);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitStatus counts untracked files', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
// Create untracked files
|
||||
await writeFile(path.join(dir, 'untracked1.txt'), 'content');
|
||||
await writeFile(path.join(dir, 'untracked2.txt'), 'content');
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result?.fileStats?.untracked, 2);
|
||||
assert.equal(result?.fileStats?.modified, 0);
|
||||
assert.equal(result?.fileStats?.added, 0);
|
||||
assert.equal(result?.fileStats?.deleted, 0);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitStatus counts modified files', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
// Create and commit a file
|
||||
await writeFile(path.join(dir, 'file.txt'), 'original');
|
||||
execFileSync('git', ['add', 'file.txt'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '-m', 'add file'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
// Modify the file
|
||||
await writeFile(path.join(dir, 'file.txt'), 'modified');
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result?.fileStats?.modified, 1);
|
||||
assert.equal(result?.fileStats?.untracked, 0);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitStatus counts staged added files', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
// Create and stage a new file
|
||||
await writeFile(path.join(dir, 'newfile.txt'), 'content');
|
||||
execFileSync('git', ['add', 'newfile.txt'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result?.fileStats?.added, 1);
|
||||
assert.equal(result?.fileStats?.untracked, 0);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getGitStatus counts deleted files', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
// Create, commit, then delete a file
|
||||
await writeFile(path.join(dir, 'todelete.txt'), 'content');
|
||||
execFileSync('git', ['add', 'todelete.txt'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['commit', '-m', 'add file'], { cwd: dir, stdio: 'ignore' });
|
||||
execFileSync('git', ['rm', 'todelete.txt'], { cwd: dir, stdio: 'ignore' });
|
||||
|
||||
const result = await getGitStatus(dir);
|
||||
assert.equal(result?.fileStats?.deleted, 1);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -556,3 +556,81 @@ test('render omits separator when layout is separators but no activity', () => {
|
||||
assert.ok(!logs.some(l => l.includes('─')), 'should not include separator');
|
||||
});
|
||||
|
||||
// fileStats tests
|
||||
test('renderSessionLine displays file stats when showFileStats is true', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
ctx.config.gitStatus.showFileStats = true;
|
||||
ctx.gitStatus = {
|
||||
branch: 'main',
|
||||
isDirty: true,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fileStats: { modified: 2, added: 1, deleted: 0, untracked: 3 },
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('!2'), 'expected modified count');
|
||||
assert.ok(line.includes('+1'), 'expected added count');
|
||||
assert.ok(line.includes('?3'), 'expected untracked count');
|
||||
assert.ok(!line.includes('✘'), 'should not show deleted when 0');
|
||||
});
|
||||
|
||||
test('renderSessionLine omits file stats when showFileStats is false', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
ctx.config.gitStatus.showFileStats = false;
|
||||
ctx.gitStatus = {
|
||||
branch: 'main',
|
||||
isDirty: true,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fileStats: { modified: 2, added: 1, deleted: 0, untracked: 3 },
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(!line.includes('!2'), 'should not show modified count');
|
||||
assert.ok(!line.includes('+1'), 'should not show added count');
|
||||
});
|
||||
|
||||
test('renderSessionLine handles missing showFileStats config (backward compatibility)', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
// Simulate old config without showFileStats
|
||||
delete ctx.config.gitStatus.showFileStats;
|
||||
ctx.gitStatus = {
|
||||
branch: 'main',
|
||||
isDirty: true,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fileStats: { modified: 2, added: 1, deleted: 0, untracked: 3 },
|
||||
};
|
||||
// Should not crash and should not show file stats (default is false)
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('git:('), 'should still show git info');
|
||||
assert.ok(!line.includes('!2'), 'should not show file stats when config missing');
|
||||
});
|
||||
|
||||
test('renderSessionLine combines showFileStats with showDirty and showAheadBehind', () => {
|
||||
const ctx = baseContext();
|
||||
ctx.stdin.cwd = '/tmp/my-project';
|
||||
ctx.config.gitStatus = {
|
||||
enabled: true,
|
||||
showDirty: true,
|
||||
showAheadBehind: true,
|
||||
showFileStats: true,
|
||||
};
|
||||
ctx.gitStatus = {
|
||||
branch: 'feature',
|
||||
isDirty: true,
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
fileStats: { modified: 3, added: 0, deleted: 1, untracked: 0 },
|
||||
};
|
||||
const line = renderSessionLine(ctx);
|
||||
assert.ok(line.includes('feature'), 'expected branch name');
|
||||
assert.ok(line.includes('*'), 'expected dirty indicator');
|
||||
assert.ok(line.includes('↑2'), 'expected ahead count');
|
||||
assert.ok(line.includes('↓1'), 'expected behind count');
|
||||
assert.ok(line.includes('!3'), 'expected modified count');
|
||||
assert.ok(line.includes('✘1'), 'expected deleted count');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user