diff --git a/.changeset/fix-detached-head-git-status.md b/.changeset/fix-detached-head-git-status.md new file mode 100644 index 000000000..beb0ac9bd --- /dev/null +++ b/.changeset/fix-detached-head-git-status.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show the current commit in git status when the repository is in detached HEAD. diff --git a/apps/kimi-code/src/utils/git/git-status.ts b/apps/kimi-code/src/utils/git/git-status.ts index c77256f01..8b67bf5d9 100644 --- a/apps/kimi-code/src/utils/git/git-status.ts +++ b/apps/kimi-code/src/utils/git/git-status.ts @@ -39,8 +39,13 @@ export interface GitStatusCacheOptions { readonly onChange?: () => void; } +interface BranchInfo { + readonly name: string; + readonly detached: boolean; +} + interface BranchState { - value: string | null; + value: BranchInfo | null; fetchedAt: number; } @@ -93,21 +98,34 @@ export function createGitStatusCache( if (now - branch.fetchedAt >= BRANCH_TTL_MS) { branch = { value: readBranch(workDir), fetchedAt: now }; } - if (branch.value === null) return null; + const branchInfo = branch.value; + if (branchInfo === null) return null; if (now - status.fetchedAt >= STATUS_TTL_MS) { status = { ...readStatus(workDir), fetchedAt: now }; } - refreshPullRequestIfNeeded(branch.value, now); + if (branchInfo.detached) { + const requestId = + pullRequest.pendingBranch === null ? pullRequest.requestId : pullRequest.requestId + 1; + pullRequest = { + value: null, + branch: null, + fetchedAt: 0, + pendingBranch: null, + requestId, + }; + } else { + refreshPullRequestIfNeeded(branchInfo.name, now); + } return { - branch: branch.value, + branch: branchInfo.name, dirty: status.dirty, ahead: status.ahead, behind: status.behind, diffAdded: status.diffAdded, diffDeleted: status.diffDeleted, - pullRequest: pullRequest.branch === branch.value ? pullRequest.value : null, + pullRequest: pullRequest.branch === branchInfo.name ? pullRequest.value : null, }; }, }; @@ -155,7 +173,7 @@ function detectGitRepo(workDir: string): boolean { } } -function readBranch(workDir: string): string | null { +function readBranch(workDir: string): BranchInfo | null { try { const result = spawnSync('git', ['-C', workDir, 'branch', '--show-current'], { encoding: 'utf8', @@ -163,7 +181,23 @@ function readBranch(workDir: string): string | null { }); if (result.status !== 0) return null; const name = result.stdout.trim(); - return name.length > 0 ? name : null; + return name.length > 0 ? { name, detached: false } : readDetachedHead(workDir); + } catch { + return null; + } +} + +function readDetachedHead(workDir: string): BranchInfo | null { + try { + const result = spawnSync('git', ['-C', workDir, 'rev-parse', '--short', 'HEAD'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + }); + if (result.status !== 0) return null; + + const commit = result.stdout.trim(); + if (!/^[0-9a-fA-F]+$/.test(commit)) return null; + return { name: `detached@${commit}`, detached: true }; } catch { return null; } diff --git a/apps/kimi-code/test/utils/git/git-status.test.ts b/apps/kimi-code/test/utils/git/git-status.test.ts index 951816fd2..9024a3498 100644 --- a/apps/kimi-code/test/utils/git/git-status.test.ts +++ b/apps/kimi-code/test/utils/git/git-status.test.ts @@ -149,6 +149,96 @@ describe('git status cache', () => { }); }); + it('shows detached HEAD as the current commit without looking up a pull request', () => { + mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes('--is-inside-work-tree')) { + return { status: 0, stdout: 'true\n' }; + } + if (args.includes('branch')) { + return { status: 0, stdout: '' }; + } + if (args.includes('--short')) { + return { status: 0, stdout: '3a22346\n' }; + } + if (args.includes('status')) { + return { status: 0, stdout: '## HEAD (no branch)\n' }; + } + return { status: 1, stdout: '' }; + }); + + const cache = createGitStatusCache('/tmp/repo'); + + expect(cache.getStatus()).toEqual({ + branch: 'detached@3a22346', + dirty: false, + ahead: 0, + behind: 0, + diffAdded: 0, + diffDeleted: 0, + pullRequest: null, + }); + expect(mocks.execFile).not.toHaveBeenCalled(); + }); + + it('ignores stale pull request results after switching to detached HEAD', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-24T00:00:00Z')); + + const onChange = vi.fn(); + let branchReads = 0; + let prCallback: + | ((error: Error | null, stdout: string, stderr: string) => void) + | undefined; + + mocks.execFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + prCallback = callback; + }, + ); + mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes('--is-inside-work-tree')) { + return { status: 0, stdout: 'true\n' }; + } + if (args.includes('branch')) { + branchReads += 1; + return { status: 0, stdout: branchReads === 1 ? 'feature/footer\n' : '' }; + } + if (args.includes('--short')) { + return { status: 0, stdout: '3a22346\n' }; + } + if (args.includes('status')) { + return { status: 0, stdout: '## HEAD (no branch)\n' }; + } + return { status: 1, stdout: '' }; + }); + + const cache = createGitStatusCache('/tmp/repo', { onChange }); + expect(cache.getStatus()?.branch).toBe('feature/footer'); + expect(mocks.execFile).toHaveBeenCalledTimes(1); + + vi.setSystemTime(new Date('2026-04-24T00:00:06Z')); + expect(cache.getStatus()?.branch).toBe('detached@3a22346'); + + prCallback?.(null, '{"number":12,"url":"https://github.com/acme/repo/pull/12"}\n', ''); + await Promise.resolve(); + + expect(onChange).not.toHaveBeenCalled(); + expect(cache.getStatus()).toEqual({ + branch: 'detached@3a22346', + dirty: false, + ahead: 0, + behind: 0, + diffAdded: 0, + diffDeleted: 0, + pullRequest: null, + }); + }); + it('keeps footer git status working when gh pull-request lookup throws synchronously', async () => { const onChange = vi.fn(); mocks.execFile.mockImplementation(() => {