diff --git a/.changeset/fix-footer-pr-lookup-crash.md b/.changeset/fix-footer-pr-lookup-crash.md new file mode 100644 index 0000000..b7336c3 --- /dev/null +++ b/.changeset/fix-footer-pr-lookup-crash.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Prevent the TUI from crashing when pull request lookup fails during startup. diff --git a/apps/kimi-code/src/utils/git/git-status.ts b/apps/kimi-code/src/utils/git/git-status.ts index d78358b..c77256f 100644 --- a/apps/kimi-code/src/utils/git/git-status.ts +++ b/apps/kimi-code/src/utils/git/git-status.ts @@ -244,28 +244,32 @@ function parseDiffNumstatCount(value: string | undefined): number { function readPullRequest(workDir: string): Promise { return new Promise((resolve) => { - execFile( - 'gh', - ['pr', 'view', '--json', 'number,url'], - { - cwd: workDir, - encoding: 'utf8', - env: { - ...process.env, - GH_NO_UPDATE_NOTIFIER: '1', - GH_PROMPT_DISABLED: '1', + try { + execFile( + 'gh', + ['pr', 'view', '--json', 'number,url'], + { + cwd: workDir, + encoding: 'utf8', + env: { + ...process.env, + GH_NO_UPDATE_NOTIFIER: '1', + GH_PROMPT_DISABLED: '1', + }, + timeout: PR_SPAWN_TIMEOUT_MS, + maxBuffer: 256 * 1024, }, - timeout: PR_SPAWN_TIMEOUT_MS, - maxBuffer: 256 * 1024, - }, - (error, stdout) => { - if (error !== null) { - resolve(null); - return; - } - resolve(parsePullRequest(stdout)); - }, - ); + (error, stdout) => { + if (error !== null) { + resolve(null); + return; + } + resolve(parsePullRequest(stdout)); + }, + ); + } catch { + resolve(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 fed27dd..951816f 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,57 @@ describe('git status cache', () => { }); }); + it('keeps footer git status working when gh pull-request lookup throws synchronously', async () => { + const onChange = vi.fn(); + mocks.execFile.mockImplementation(() => { + const error = Object.assign(new Error('spawn ENOTDIR'), { code: 'ENOTDIR' }); + throw error; + }); + mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes('rev-parse')) { + return { status: 0, stdout: 'true\n' }; + } + if (args.includes('branch')) { + return { status: 0, stdout: 'main\n' }; + } + if (args.includes('status')) { + return { + status: 0, + stdout: '## main...origin/main\n M src/app.ts\n', + }; + } + if (args.includes('diff')) { + return { status: 0, stdout: '2\t1\tsrc/app.ts\n' }; + } + return { status: 1, stdout: '' }; + }); + + const cache = createGitStatusCache('/tmp/repo', { onChange }); + + expect(cache.getStatus()).toEqual({ + branch: 'main', + dirty: true, + ahead: 0, + behind: 0, + diffAdded: 2, + diffDeleted: 1, + pullRequest: null, + }); + + await Promise.resolve(); + + expect(onChange).not.toHaveBeenCalled(); + expect(cache.getStatus()).toEqual({ + branch: 'main', + dirty: true, + ahead: 0, + behind: 0, + diffAdded: 2, + diffDeleted: 1, + pullRequest: null, + }); + }); + it('returns null when the working directory is not a git repo and formats badges', () => { mocks.spawnSync.mockReturnValue({ status: 1, stdout: '' }); expect(createGitStatusCache('/tmp/not-a-repo').getStatus()).toBeNull();