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:
meijin
2026-01-14 08:55:30 +09:00
committed by GitHub
parent 49ed356f84
commit 227b536076
6 changed files with 247 additions and 6 deletions

View File

@@ -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 }` |
---

View File

@@ -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 = {

View File

@@ -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;
}

View File

@@ -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(')')}`;
}

View File

@@ -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 });
}
});

View File

@@ -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');
});