From 227b536076359e2940f67faccb883bc4e941a9d1 Mon Sep 17 00:00:00 2001 From: meijin Date: Wed, 14 Jan 2026 08:55:30 +0900 Subject: [PATCH] feat(git): Add file stats display (Starship-compatible format) (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- commands/configure.md | 8 +-- src/config.ts | 5 ++ src/git.ts | 48 ++++++++++++++++-- src/render/session-line.ts | 13 +++++ tests/git.test.js | 101 +++++++++++++++++++++++++++++++++++++ tests/render.test.js | 78 ++++++++++++++++++++++++++++ 6 files changed, 247 insertions(+), 6 deletions(-) diff --git a/commands/configure.md b/commands/configure.md index 909c9f7..831ac9a 100644 --- a/commands/configure.md +++ b/commands/configure.md @@ -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 }` | --- diff --git a/src/config.ts b/src/config.ts index 2f4ad72..987caae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { 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 = { diff --git a/src/git.ts b/src/git.ts index 8f556e7..376af76 100644 --- a/src/git.ts +++ b/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 { @@ -38,15 +46,20 @@ export async function getGitStatus(cwd?: string): Promise { 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 { // 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; +} diff --git a/src/render/session-line.ts b/src/render/session-line.ts index aebdeda..666be80 100644 --- a/src/render/session-line.ts +++ b/src/render/session-line.ts @@ -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(')')}`; } diff --git a/tests/git.test.js b/tests/git.test.js index 86855f3..73bcb4e 100644 --- a/tests/git.test.js +++ b/tests/git.test.js @@ -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 }); + } +}); diff --git a/tests/render.test.js b/tests/render.test.js index 0990524..67f81e5 100644 --- a/tests/render.test.js +++ b/tests/render.test.js @@ -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'); +}); +