Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-detached-head-git-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show the current commit in git status when the repository is in detached HEAD.
48 changes: 41 additions & 7 deletions apps/kimi-code/src/utils/git/git-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
};
},
};
Expand Down Expand Up @@ -155,15 +173,31 @@ 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',
timeout: SPAWN_TIMEOUT_MS,
});
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;
}
Expand Down
90 changes: 90 additions & 0 deletions apps/kimi-code/test/utils/git/git-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Loading