Skip to content

Commit 025b873

Browse files
cahaselerclaude
andauthored
feat: add repo name display to statusline with show_repo config option (#181)
- Add StatuslineConfig interface with show_repo option (default: true) - Add getRepoName() to GitHelpers to extract repo from git remote URL or directory name - Display repo name at start of statusline second line when enabled - Fix hardcoded year tests in pre-tool-validation.test.ts to use dynamic getCurrentYear() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Craig Haseler <cahaseler@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 365ce2d commit 025b873

File tree

6 files changed

+331
-36
lines changed

6 files changed

+331
-36
lines changed

scripts/statusline.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getCostEmoji,
77
getCostInfo,
88
getCurrentBranch,
9+
getRepoName,
910
getTodaysCost,
1011
getTokenInfo,
1112
} from './statusline';
@@ -190,6 +191,42 @@ describe('statusline', () => {
190191
});
191192
});
192193

194+
describe('getRepoName', () => {
195+
test('returns repository name from mock', () => {
196+
const mockDeps = {
197+
execSync: mock(() => ''),
198+
existsSync: mock(() => false),
199+
readFileSync: mock(() => ''),
200+
getConfig: mock(() => ({ features: {} })),
201+
getCurrentBranch: mock((_cwd: string) => ''),
202+
getRepoName: mock((_cwd: string) => 'my-awesome-project'),
203+
getActiveTaskId: mock(() => null),
204+
getActiveSpecDirectory: mock(() => null),
205+
};
206+
207+
const result = getRepoName(mockDeps);
208+
expect(result).toBe('my-awesome-project');
209+
});
210+
211+
test('handles errors gracefully', () => {
212+
const mockDeps = {
213+
execSync: mock(() => ''),
214+
existsSync: mock(() => false),
215+
readFileSync: mock(() => ''),
216+
getConfig: mock(() => ({ features: {} })),
217+
getCurrentBranch: mock((_cwd: string) => ''),
218+
getRepoName: mock((_cwd: string) => {
219+
throw new Error('Not a git repository');
220+
}),
221+
getActiveTaskId: mock(() => null),
222+
getActiveSpecDirectory: mock(() => null),
223+
};
224+
225+
const result = getRepoName(mockDeps);
226+
expect(result).toBe('');
227+
});
228+
});
229+
193230
describe('getActiveTask', () => {
194231
test('extracts active task from CLAUDE.md', () => {
195232
const mockDeps = {
@@ -533,6 +570,108 @@ describe('statusline', () => {
533570
expect(lines[1]).not.toContain('Autoflow');
534571
expect(lines[1]).toBe('main');
535572
});
573+
574+
test('shows repo name at start of second line when show_repo is true (default)', () => {
575+
const mockDeps = {
576+
execSync: mock(() => '{}'),
577+
existsSync: mock(() => false),
578+
readFileSync: mock(() => ''),
579+
getConfig: mock(() => ({
580+
features: {
581+
statusline: { enabled: true, show_repo: true },
582+
},
583+
})),
584+
getCurrentBranch: mock((_cwd: string) => 'feature/test'),
585+
getRepoName: mock((_cwd: string) => 'my-project'),
586+
getActiveTaskId: mock(() => null),
587+
getActiveSpecDirectory: mock(() => null),
588+
};
589+
590+
const result = generateStatusLine({ model: { display_name: 'Claude Sonnet' } }, mockDeps);
591+
const lines = result.split('\n');
592+
593+
// Second line should start with repo name
594+
expect(lines[1]).toMatch(/^my-project/);
595+
expect(lines[1]).toBe('my-project | feature/test');
596+
});
597+
598+
test('shows repo name by default when statusline config has no show_repo setting', () => {
599+
const mockDeps = {
600+
execSync: mock(() => '{}'),
601+
existsSync: mock(() => false),
602+
readFileSync: mock(() => ''),
603+
getConfig: mock(() => ({
604+
features: {
605+
statusline: { enabled: true },
606+
},
607+
})),
608+
getCurrentBranch: mock((_cwd: string) => 'main'),
609+
getRepoName: mock((_cwd: string) => 'cc-track'),
610+
getActiveTaskId: mock(() => null),
611+
getActiveSpecDirectory: mock(() => null),
612+
};
613+
614+
const result = generateStatusLine({ model: { display_name: 'Claude Sonnet' } }, mockDeps);
615+
const lines = result.split('\n');
616+
617+
// Default should show repo name
618+
expect(lines[1]).toContain('cc-track');
619+
expect(lines[1]).toBe('cc-track | main');
620+
});
621+
622+
test('hides repo name when show_repo is false', () => {
623+
const mockDeps = {
624+
execSync: mock(() => '{}'),
625+
existsSync: mock(() => false),
626+
readFileSync: mock(() => ''),
627+
getConfig: mock(() => ({
628+
features: {
629+
statusline: { enabled: true, show_repo: false },
630+
},
631+
})),
632+
getCurrentBranch: mock((_cwd: string) => 'main'),
633+
getRepoName: mock((_cwd: string) => 'my-project'),
634+
getActiveTaskId: mock(() => null),
635+
getActiveSpecDirectory: mock(() => null),
636+
};
637+
638+
const result = generateStatusLine({ model: { display_name: 'Claude Sonnet' } }, mockDeps);
639+
const lines = result.split('\n');
640+
641+
// Repo name should not be shown
642+
expect(lines[1]).not.toContain('my-project');
643+
expect(lines[1]).toBe('main');
644+
});
645+
646+
test('repo name appears before autoflow indicator', () => {
647+
const mockDeps = {
648+
execSync: mock(() => '{}'),
649+
existsSync: mock((path: string) => path.includes('.autoflow-state.json')),
650+
readFileSync: mock(() =>
651+
JSON.stringify({
652+
active: true,
653+
sessionId: 'test-123',
654+
activatedAt: '2025-01-01T00:00:00Z',
655+
continueCount: 2,
656+
}),
657+
),
658+
getConfig: mock(() => ({
659+
features: {
660+
statusline: { enabled: true, show_repo: true },
661+
},
662+
})),
663+
getCurrentBranch: mock((_cwd: string) => 'feature/autoflow'),
664+
getRepoName: mock((_cwd: string) => 'my-project'),
665+
getActiveTaskId: mock(() => null),
666+
getActiveSpecDirectory: mock(() => null),
667+
};
668+
669+
const result = generateStatusLine({ model: { display_name: 'Claude Sonnet' } }, mockDeps);
670+
const lines = result.split('\n');
671+
672+
// Second line should have repo first, then autoflow, then branch
673+
expect(lines[1]).toBe('my-project | 🤖 Autoflow (2/3) | feature/autoflow');
674+
});
536675
});
537676

538677
describe('direct execution (import.meta.main)', () => {

scripts/statusline.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ interface CommandResult<T = unknown> {
2424
data?: T;
2525
}
2626

27-
import { getConfig as getConfigImpl } from '../skills/cc-track-tools/lib/config';
28-
import { getCurrentBranch as getCurrentBranchImpl } from '../skills/cc-track-tools/lib/git-helpers';
27+
import { getConfig as getConfigImpl, type StatuslineConfig } from '../skills/cc-track-tools/lib/config';
28+
import {
29+
getCurrentBranch as getCurrentBranchImpl,
30+
getRepoName as getRepoNameImpl,
31+
} from '../skills/cc-track-tools/lib/git-helpers';
2932
import { getActiveSpecDirectory } from '../skills/cc-track-tools/lib/spec-helpers';
3033

3134
interface CurrentUsage {
@@ -55,6 +58,7 @@ interface StatusLineDeps {
5558
readFileSync: typeof nodeReadFileSync;
5659
getConfig: typeof getConfigImpl;
5760
getCurrentBranch: typeof getCurrentBranchImpl;
61+
getRepoName: typeof getRepoNameImpl;
5862
getActiveTaskId: typeof getActiveTaskId;
5963
getActiveSpecDirectory: typeof getActiveSpecDirectory;
6064
}
@@ -65,6 +69,7 @@ const defaultDeps: StatusLineDeps = {
6569
readFileSync: nodeReadFileSync,
6670
getConfig: getConfigImpl,
6771
getCurrentBranch: getCurrentBranchImpl,
72+
getRepoName: getRepoNameImpl,
6873
getActiveTaskId,
6974
getActiveSpecDirectory,
7075
};
@@ -145,6 +150,17 @@ export function getCurrentBranch(deps = defaultDeps): string {
145150
}
146151
}
147152

153+
/**
154+
* Get current repository name
155+
*/
156+
export function getRepoName(deps = defaultDeps): string {
157+
try {
158+
return deps.getRepoName(process.cwd());
159+
} catch {
160+
return '';
161+
}
162+
}
163+
148164
/**
149165
* Get active task from CLAUDE.md
150166
*/
@@ -234,9 +250,11 @@ export function generateStatusLine(input: StatusLineInput, deps = defaultDeps):
234250
const branch = getCurrentBranch(deps);
235251
const task = getActiveTask(deps);
236252

237-
// Get API timer config
253+
// Get config
238254
const config = deps.getConfig();
239255
const apiTimerDisplay = config.features?.api_timer?.display || 'sonnet-only';
256+
const statuslineConfig = config.features?.statusline as StatuslineConfig | undefined;
257+
const showRepo = statuslineConfig?.show_repo !== false; // Default to true
240258

241259
// Build first line
242260
let firstLine = `🚅 ${modelName}`;
@@ -272,10 +290,20 @@ export function generateStatusLine(input: StatusLineInput, deps = defaultDeps):
272290
// Build second line
273291
let secondLine = '';
274292

293+
// Add repo name if enabled
294+
if (showRepo) {
295+
const repo = getRepoName(deps);
296+
if (repo) {
297+
secondLine = repo;
298+
}
299+
}
300+
275301
// Check autoflow state
276302
const autoflow = getAutoflowState(deps);
277303
if (autoflow.active) {
278-
secondLine = `🤖 Autoflow (${autoflow.continueCount}/3)`;
304+
secondLine += secondLine
305+
? ` | 🤖 Autoflow (${autoflow.continueCount}/3)`
306+
: `🤖 Autoflow (${autoflow.continueCount}/3)`;
279307
}
280308

281309
if (branch) {

skills/cc-track-tools/lib/config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export interface CodeReviewConfig extends HookConfig {
3737
max_diff_size?: number;
3838
}
3939

40+
export interface StatuslineConfig extends HookConfig {
41+
show_repo?: boolean;
42+
}
43+
4044
interface GitConfig {
4145
defaultBranch?: string;
4246
description?: string;
@@ -56,7 +60,7 @@ interface InternalConfig {
5660
[key: string]: HookConfig | EditValidationConfig;
5761
};
5862
features: {
59-
[key: string]: HookConfig | CodeReviewConfig;
63+
[key: string]: HookConfig | CodeReviewConfig | StatuslineConfig;
6064
};
6165
git?: GitConfig;
6266
logging?: LoggingConfig;
@@ -111,6 +115,7 @@ const DEFAULT_CONFIG: InternalConfig = {
111115
statusline: {
112116
enabled: true,
113117
description: 'Custom status line showing costs and task info',
118+
show_repo: true,
114119
},
115120
git_branching: {
116121
enabled: false,

skills/cc-track-tools/lib/git-helpers.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,73 @@ describe('GitHelpers', () => {
247247
});
248248
});
249249

250+
describe('getRepoName', () => {
251+
test('extracts repo name from HTTPS URL', () => {
252+
mockExec = mock(() => 'https://github.com/owner/my-repo.git\n');
253+
gitHelpers = new GitHelpers(mockExec, undefined, mockClaudeSDK, mockLogger);
254+
255+
expect(gitHelpers.getRepoName('/test')).toBe('my-repo');
256+
});
257+
258+
test('extracts repo name from HTTPS URL without .git suffix', () => {
259+
mockExec = mock(() => 'https://github.com/owner/my-repo\n');
260+
gitHelpers = new GitHelpers(mockExec, undefined, mockClaudeSDK, mockLogger);
261+
262+
expect(gitHelpers.getRepoName('/test')).toBe('my-repo');
263+
});
264+
265+
test('extracts repo name from SSH URL', () => {
266+
mockExec = mock((cmd: string) => {
267+
if (cmd.includes('remote.origin.url')) {
268+
return 'git@github.com:owner/my-repo.git\n';
269+
}
270+
return '';
271+
});
272+
gitHelpers = new GitHelpers(mockExec, undefined, mockClaudeSDK, mockLogger);
273+
274+
expect(gitHelpers.getRepoName('/test')).toBe('my-repo');
275+
});
276+
277+
test('falls back to directory name when no remote', () => {
278+
mockExec = mock((cmd: string) => {
279+
if (cmd.includes('remote.origin.url')) {
280+
throw new Error('No remote');
281+
}
282+
if (cmd.includes('rev-parse --show-toplevel')) {
283+
return '/home/user/projects/my-project\n';
284+
}
285+
return '';
286+
});
287+
gitHelpers = new GitHelpers(mockExec, undefined, mockClaudeSDK, mockLogger);
288+
289+
expect(gitHelpers.getRepoName('/test')).toBe('my-project');
290+
});
291+
292+
test('handles Windows paths in fallback', () => {
293+
mockExec = mock((cmd: string) => {
294+
if (cmd.includes('remote.origin.url')) {
295+
throw new Error('No remote');
296+
}
297+
if (cmd.includes('rev-parse --show-toplevel')) {
298+
return 'C:\\Users\\dev\\projects\\my-project\n';
299+
}
300+
return '';
301+
});
302+
gitHelpers = new GitHelpers(mockExec, undefined, mockClaudeSDK, mockLogger);
303+
304+
expect(gitHelpers.getRepoName('/test')).toBe('my-project');
305+
});
306+
307+
test('returns empty string when not a git repo', () => {
308+
mockExec = mock(() => {
309+
throw new Error('Not a git repo');
310+
});
311+
gitHelpers = new GitHelpers(mockExec, undefined, mockClaudeSDK, mockLogger);
312+
313+
expect(gitHelpers.getRepoName('/test')).toBe('');
314+
});
315+
});
316+
250317
describe('createTaskBranch', () => {
251318
test('creates and switches to new branch', () => {
252319
mockExec = mock(

0 commit comments

Comments
 (0)