From 8f6aa989fe86a3b997e1ce910b91337f4297971b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 24 Jun 2026 15:01:25 +0800 Subject: [PATCH 1/3] fix(agent-core): surface git context failures for explore subagents collectGitContext collapsed every git failure (spawn error, non-zero exit, timeout) into null, so explore subagents silently lost git context with no signal. Now a definitive 'not a git repository' injects an explicit unavailable signal so the subagent does not waste turns probing git history, while other failures are logged and surface as an empty block. The block is all-or-nothing so a partial snapshot (e.g. a timed-out status making a dirty tree look clean) is never shown. --- .changeset/fix-git-context-silent-failure.md | 5 + .../agent-core/src/session/git-context.ts | 176 ++++++++++++++---- .../test/session/git-context.test.ts | 86 ++++++++- 3 files changed, 225 insertions(+), 42 deletions(-) create mode 100644 .changeset/fix-git-context-silent-failure.md diff --git a/.changeset/fix-git-context-silent-failure.md b/.changeset/fix-git-context-silent-failure.md new file mode 100644 index 0000000000..416fe9763f --- /dev/null +++ b/.changeset/fix-git-context-silent-failure.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix explore subagents silently losing git context when git commands time out or the directory is not a repository. diff --git a/packages/agent-core/src/session/git-context.ts b/packages/agent-core/src/session/git-context.ts index ada2b1ad2a..c2de9b8bfd 100644 --- a/packages/agent-core/src/session/git-context.ts +++ b/packages/agent-core/src/session/git-context.ts @@ -3,15 +3,24 @@ * * `collectGitContext` produces a `` block that is prepended to a * fresh explore subagent's prompt so it can orient itself in the repository - * before searching. Every git command is individually guarded — a single - * failure never aborts the whole collection — and remote URLs are sanitized - * so internal infrastructure is not surfaced to the model. + * before searching. The block is all-or-nothing for the probes that can + * mislead: if `git status` or `git branch` fails, the whole block is dropped + * (and logged) rather than showing a partial snapshot — e.g. a timed-out + * `git status` would make a dirty tree look clean. Optional probes — the + * `origin` remote and recent commits — degrade to an empty section when + * unavailable, since a missing remote or no commits yet are normal repo + * states. The only explicit state surfaced to the subagent is + * `reason="not-a-repo"`, so it doesn't waste turns probing git history in a + * non-repo directory. Remote URLs are sanitized so internal infrastructure + * is not surfaced to the model. */ import type { Readable } from 'node:stream'; import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; +import { log } from '../logging/logger'; + const GIT_TIMEOUT_MS = 5_000; const MAX_DIRTY_FILES = 20; const MAX_COMMIT_LINE_LENGTH = 200; @@ -42,17 +51,63 @@ async function disposeProcess(proc: KaosProcess): Promise { * directory is not a git repository or no useful information was collected. */ export async function collectGitContext(kaos: Kaos, cwd: string): Promise { - // Quick check: is this a git repo? - if ((await runGit(kaos, cwd, ['rev-parse', '--is-inside-work-tree'])) === null) { + // Step 1: is this a git repo? `rev-parse` is the authoritative probe — it + // handles `.git` files (worktrees/submodules), subdirectories, bare repos, + // and `$GIT_DIR` redirection, none of which a plain FS check covers. + const revParseArgs = ['rev-parse', '--is-inside-work-tree'] as const; + const revParse = await runGit(kaos, cwd, revParseArgs); + if (!revParse.ok) { + if (revParse.kind === 'command-failed' && isNotARepo(revParse.stderr)) { + // Definitive "not a repo" — tell the subagent so it doesn't waste turns + // probing git history. All other failures are logged but surface as an + // empty block (the subagent works without git context, same as before): + // a transient `git status` hang shouldn't read as "git is broken". + return ``; + } + logGitFailure(cwd, revParseArgs, revParse); + return ''; + } + + // Step 2: collect context in parallel. Probes split into two classes: + // + // - fatal (`status`, `branch`): if either fails, drop the whole block. A + // missing `status` would make a dirty tree look clean, and a non-zero + // `branch` exit means git itself is broken (detached HEAD is exit 0). + // - optional (`remote`, `log`): a missing `origin` remote or a repo with + // no commits yet are normal states, not collection failures. Log at + // debug and leave that section empty rather than dropping the block. + const commandArgs = [ + ['remote', 'get-url', 'origin'], + ['branch', '--show-current'], + ['status', '--porcelain'], + ['log', '-3', '--format=%h %s'], + ] as const; + const [remote, branch, status, gitLog] = (await Promise.all( + commandArgs.map(async (args) => ({ args, result: await runGit(kaos, cwd, args) })), + )) as unknown as [TaggedGitResult, TaggedGitResult, TaggedGitResult, TaggedGitResult]; + + const fatal = [branch, status].filter(({ result }) => !result.ok); + if (fatal.length > 0) { + for (const { args, result } of fatal) { + if (!result.ok) logGitFailure(cwd, args, result); + } return ''; } - const [remoteUrl, branch, dirtyRaw, logRaw] = await Promise.all([ - runGit(kaos, cwd, ['remote', 'get-url', 'origin']), - runGit(kaos, cwd, ['branch', '--show-current']), - runGit(kaos, cwd, ['status', '--porcelain']), - runGit(kaos, cwd, ['log', '-3', '--format=%h %s']), - ]); + for (const { args, result } of [remote, gitLog]) { + if (!result.ok) { + log.debug('git context optional probe unavailable', { + cwd, + command: `git ${args.join(' ')}`, + reason: result.kind, + }); + } + } + + const remoteUrl = stdoutOf(remote.result); + const branchName = stdoutOf(branch.result); + const dirtyRaw = stdoutOf(status.result); + const logRaw = stdoutOf(gitLog.result); const sections: string[] = [`Working directory: ${cwd}`]; @@ -67,19 +122,17 @@ export async function collectGitContext(kaos: Kaos, cwd: string): Promise line.trim().length > 0); - if (dirtyLines.length > 0) { - const total = dirtyLines.length; - const shown = dirtyLines.slice(0, MAX_DIRTY_FILES); - let body = shown.map((line) => ` ${line}`).join('\n'); - if (total > MAX_DIRTY_FILES) { - body += `\n ... and ${String(total - MAX_DIRTY_FILES)} more`; - } - sections.push(`Dirty files (${String(total)}):\n${body}`); + const dirtyLines = dirtyRaw.split('\n').filter((line) => line.trim().length > 0); + if (dirtyLines.length > 0) { + const total = dirtyLines.length; + const shown = dirtyLines.slice(0, MAX_DIRTY_FILES); + let body = shown.map((line) => ` ${line}`).join('\n'); + if (total > MAX_DIRTY_FILES) { + body += `\n ... and ${String(total - MAX_DIRTY_FILES)} more`; } + sections.push(`Dirty files (${String(total)}):\n${body}`); } if (logRaw) { @@ -135,7 +188,10 @@ export function parseProjectName(remoteUrl: string): string | null { const scp = /^[^/]+@[^/:]+:(.+)$/.exec(remoteUrl); const rawPath = scp?.[1] ?? tryUrlPath(remoteUrl); if (rawPath === null) return null; - const project = rawPath.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/, ''); + const project = rawPath + .replace(/^\/+/, '') + .replace(/\/+$/, '') + .replace(/\.git$/, ''); return project.length > 0 ? project : null; } @@ -148,16 +204,61 @@ function tryUrlPath(remoteUrl: string): string | null { } /** - * Run a single `git -C ` command and return its trimmed stdout, - * or `null` on any failure (spawn error, non-zero exit, or timeout). The - * `git -C` form runs in the target directory regardless of the Kaos backend. + * Outcome of a single `git` invocation. + * + * - `ok: true` — exited 0; `stdout` is trimmed. + * - `timeout` — exceeded `GIT_TIMEOUT_MS`; process was SIGKILLed. + * - `spawn-error` — `kaos.exec` itself rejected (git missing / backend error). + * - `command-failed` — git ran but exited non-zero, or its streams errored. + * `exitCode`/`stderr` are populated for the non-zero-exit case. */ -async function runGit(kaos: Kaos, cwd: string, args: readonly string[]): Promise { +type GitFailure = + | { readonly kind: 'timeout' } + | { readonly kind: 'spawn-error' } + | { readonly kind: 'command-failed'; readonly exitCode?: number; readonly stderr?: string }; + +type GitResult = + | { readonly ok: true; readonly stdout: string } + | ({ readonly ok: false } & GitFailure); + +type TaggedGitResult = { readonly args: readonly string[]; readonly result: GitResult }; + +function stdoutOf(result: GitResult): string { + return result.ok ? result.stdout : ''; +} + +function isNotARepo(stderr: string | undefined): boolean { + return stderr !== undefined && stderr.includes('not a git repository'); +} + +function logGitFailure(cwd: string, args: readonly string[], failure: GitFailure): void { + const command = `git ${args.join(' ')}`; + if (failure.kind === 'timeout') { + log.debug('git context command timed out', { cwd, command }); + } else if (failure.kind === 'spawn-error') { + log.warn('git context command failed to spawn', { cwd, command }); + } else { + log.debug('git context command failed', { + cwd, + command, + exitCode: failure.exitCode, + stderr: failure.stderr, + }); + } +} + +/** + * Run a single `git -C ` command and return a structured result. + * The `git -C` form runs in the target directory regardless of the Kaos + * backend. Both stdout and stderr are captured so callers can tell "not a + * git repository" (exit 128 + telltale stderr) apart from other failures. + */ +async function runGit(kaos: Kaos, cwd: string, args: readonly string[]): Promise { let proc: KaosProcess | undefined; try { proc = await kaos.exec('git', '-C', cwd, ...args); } catch { - return null; + return { ok: false, kind: 'spawn-error' }; } try { @@ -166,31 +267,36 @@ async function runGit(kaos: Kaos, cwd: string, args: readonly string[]): Promise /* stdin already closed */ } - const work = Promise.all([collectStream(proc.stdout), proc.wait()]); + const work = Promise.all([collectStream(proc.stdout), collectStream(proc.stderr), proc.wait()]); // Attach a rejection handler up front: if `work` rejects during the // timeout-handling window (before the catch block re-awaits it), Node must // not flag it as an unhandled rejection. work.catch(() => {}); let timer: ReturnType | undefined; + let timedOut = false; try { const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { + timedOut = true; reject(new Error(`git ${args.join(' ')} timed out`)); }, GIT_TIMEOUT_MS); }); - const [stdout, exitCode] = await Promise.race([work, timeout]); - if (exitCode !== 0) return null; - return stdout.trim(); + const [stdout, stderr, exitCode] = await Promise.race([work, timeout]); + if (exitCode !== 0) { + return { ok: false, kind: 'command-failed', exitCode, stderr: stderr.trim() }; + } + return { ok: true, stdout: stdout.trim() }; } catch { try { await proc.kill('SIGKILL'); } catch { /* process already gone */ } - // Let the stdout drain settle so the process resources are released, - // even though the timed-out output is discarded. + // Let the streams drain so process resources are released, even though + // the timed-out/errored output is discarded. await work.catch(() => {}); - return null; + if (timedOut) return { ok: false, kind: 'timeout' }; + return { ok: false, kind: 'command-failed' }; } finally { if (timer !== undefined) clearTimeout(timer); if (proc !== undefined) await disposeProcess(proc); diff --git a/packages/agent-core/test/session/git-context.test.ts b/packages/agent-core/test/session/git-context.test.ts index 3bf9c23442..949eae6666 100644 --- a/packages/agent-core/test/session/git-context.test.ts +++ b/packages/agent-core/test/session/git-context.test.ts @@ -3,14 +3,18 @@ import { Readable } from 'node:stream'; import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; import { describe, expect, it, vi } from 'vitest'; -import { collectGitContext, parseProjectName, sanitizeRemoteUrl } from '../../src/session/git-context'; +import { + collectGitContext, + parseProjectName, + sanitizeRemoteUrl, +} from '../../src/session/git-context'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; -function fakeProcess(stdout: string, exitCode = 0): KaosProcess { +function fakeProcess(stdout: string, exitCode = 0, stderr = ''): KaosProcess { return { stdin: { write: () => true, end: () => {} } as never, stdout: Readable.from([stdout]), - stderr: Readable.from(['']), + stderr: Readable.from([stderr]), pid: 1, exitCode, wait: async () => exitCode, @@ -20,7 +24,7 @@ function fakeProcess(stdout: string, exitCode = 0): KaosProcess { } /** Scripted git output keyed by the git subcommand (`args[3]`). */ -type GitScript = Record; +type GitScript = Record; function gitKaos(script: GitScript): Kaos { return createFakeKaos({ @@ -28,14 +32,38 @@ function gitKaos(script: GitScript): Kaos { const subcommand = args[3] ?? ''; const scripted = script[subcommand]; if (scripted === undefined) return fakeProcess('', 1); - return fakeProcess(scripted.stdout, scripted.exitCode ?? 0); + return fakeProcess(scripted.stdout, scripted.exitCode ?? 0, scripted.stderr ?? ''); }, }); } describe('collectGitContext', () => { - it('returns an empty string when the directory is not a git repository', async () => { - const kaos = gitKaos({ 'rev-parse': { stdout: '', exitCode: 1 } }); + it('returns an unavailable block when the directory is not a git repository', async () => { + const kaos = gitKaos({ + 'rev-parse': { + stdout: '', + exitCode: 128, + stderr: 'fatal: not a git repository (or any of the parent directories): .git', + }, + }); + expect(await collectGitContext(kaos, '/project')).toBe( + ``, + ); + }); + + it('returns an empty string when rev-parse fails for a reason other than not-a-repo', async () => { + const kaos = gitKaos({ + 'rev-parse': { stdout: '', exitCode: 1, stderr: 'fatal: some other git error' }, + }); + expect(await collectGitContext(kaos, '/project')).toBe(''); + }); + + it('returns an empty string when git fails to spawn', async () => { + const kaos = createFakeKaos({ + exec: async (): Promise => { + throw new Error('spawn failed'); + }, + }); expect(await collectGitContext(kaos, '/project')).toBe(''); }); @@ -66,7 +94,10 @@ describe('collectGitContext', () => { const dirty = Array.from({ length: 25 }, (_, i) => ` M src/f${String(i)}.ts`).join('\n'); const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, + remote: { stdout: '' }, + branch: { stdout: '' }, status: { stdout: dirty }, + log: { stdout: '' }, }); const block = await collectGitContext(kaos, '/project'); @@ -85,6 +116,8 @@ describe('collectGitContext', () => { 'rev-parse': { stdout: 'true' }, remote: { stdout: 'git@internal.corp:secret/repo.git' }, branch: { stdout: 'main' }, + status: { stdout: '' }, + log: { stdout: '' }, }); const block = await collectGitContext(kaos, '/project'); @@ -95,6 +128,45 @@ describe('collectGitContext', () => { expect(block).toContain('Branch: main'); }); + it('keeps branch and status when the origin remote is absent', async () => { + const kaos = gitKaos({ + 'rev-parse': { stdout: 'true' }, + remote: { stdout: '', exitCode: 2, stderr: "error: No such remote 'origin'" }, + branch: { stdout: 'main' }, + status: { stdout: ' M src/a.ts' }, + log: { stdout: 'abc123 first commit' }, + }); + + const block = await collectGitContext(kaos, '/project'); + + expect(block).toContain('Branch: main'); + expect(block).toContain('Dirty files (1):'); + expect(block).toContain('Recent commits:'); + expect(block).not.toContain('Remote:'); + expect(block).not.toContain('Project:'); + }); + + it('keeps branch and status when the repository has no commits yet', async () => { + const kaos = gitKaos({ + 'rev-parse': { stdout: 'true' }, + remote: { stdout: 'https://github.com/acme/widgets.git' }, + branch: { stdout: 'main' }, + status: { stdout: '' }, + log: { + stdout: '', + exitCode: 128, + stderr: "fatal: your current branch 'main' does not have any commits yet", + }, + }); + + const block = await collectGitContext(kaos, '/project'); + + expect(block).toContain('Branch: main'); + expect(block).toContain('Remote: https://github.com/acme/widgets.git'); + expect(block).toContain('Project: acme/widgets'); + expect(block).not.toContain('Recent commits:'); + }); + it('treats a hanging git command as a failure (timeout)', async () => { vi.useFakeTimers(); try { From b8969a525d1a5104e740fd12b687ca7ca256b46d Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 24 Jun 2026 19:05:55 +0800 Subject: [PATCH 2/3] fix(agent-core): use rev-parse for branch to support git < 2.22 `git branch --show-current` was added in Git 2.22 and fails (exit 129) on older Git even in a valid repository. Because the branch probe is fatal, this dropped the whole git-context block for older-Git users. Switch to `git rev-parse --abbrev-ref HEAD`, which is supported across Git versions, and filter the `HEAD` output produced in detached-HEAD state. --- .../agent-core/src/session/git-context.ts | 13 +++++--- .../test/session/git-context.test.ts | 32 +++++++++++++++---- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/agent-core/src/session/git-context.ts b/packages/agent-core/src/session/git-context.ts index c2de9b8bfd..cae21547a9 100644 --- a/packages/agent-core/src/session/git-context.ts +++ b/packages/agent-core/src/session/git-context.ts @@ -70,15 +70,20 @@ export async function collectGitContext(kaos: Kaos, cwd: string): Promise line.trim().length > 0); if (dirtyLines.length > 0) { diff --git a/packages/agent-core/test/session/git-context.test.ts b/packages/agent-core/test/session/git-context.test.ts index 949eae6666..81263747eb 100644 --- a/packages/agent-core/test/session/git-context.test.ts +++ b/packages/agent-core/test/session/git-context.test.ts @@ -30,7 +30,11 @@ function gitKaos(script: GitScript): Kaos { return createFakeKaos({ exec: async (...args: string[]): Promise => { const subcommand = args[3] ?? ''; - const scripted = script[subcommand]; + // Match the full git invocation first (e.g. `rev-parse --abbrev-ref + // HEAD`) so two commands sharing a subcommand (both `rev-parse`) can be + // scripted distinctly; fall back to the bare subcommand. + const full = args.slice(3).join(' '); + const scripted = script[full] ?? script[subcommand]; if (scripted === undefined) return fakeProcess('', 1); return fakeProcess(scripted.stdout, scripted.exitCode ?? 0, scripted.stderr ?? ''); }, @@ -71,7 +75,7 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: 'https://github.com/acme/widgets.git' }, - branch: { stdout: 'main' }, + 'rev-parse --abbrev-ref HEAD': { stdout: 'main' }, status: { stdout: ' M src/a.ts\n?? src/b.ts' }, log: { stdout: 'abc123 first commit\ndef456 second commit' }, }); @@ -95,7 +99,7 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: '' }, - branch: { stdout: '' }, + 'rev-parse --abbrev-ref HEAD': { stdout: '' }, status: { stdout: dirty }, log: { stdout: '' }, }); @@ -115,7 +119,7 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: 'git@internal.corp:secret/repo.git' }, - branch: { stdout: 'main' }, + 'rev-parse --abbrev-ref HEAD': { stdout: 'main' }, status: { stdout: '' }, log: { stdout: '' }, }); @@ -132,7 +136,7 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: '', exitCode: 2, stderr: "error: No such remote 'origin'" }, - branch: { stdout: 'main' }, + 'rev-parse --abbrev-ref HEAD': { stdout: 'main' }, status: { stdout: ' M src/a.ts' }, log: { stdout: 'abc123 first commit' }, }); @@ -150,7 +154,7 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: 'https://github.com/acme/widgets.git' }, - branch: { stdout: 'main' }, + 'rev-parse --abbrev-ref HEAD': { stdout: 'main' }, status: { stdout: '' }, log: { stdout: '', @@ -167,6 +171,22 @@ describe('collectGitContext', () => { expect(block).not.toContain('Recent commits:'); }); + it('omits the Branch section in detached HEAD state', async () => { + const kaos = gitKaos({ + 'rev-parse': { stdout: 'true' }, + 'rev-parse --abbrev-ref HEAD': { stdout: 'HEAD' }, + remote: { stdout: 'https://github.com/acme/widgets.git' }, + status: { stdout: '' }, + log: { stdout: 'abc123 first commit' }, + }); + + const block = await collectGitContext(kaos, '/project'); + + expect(block).not.toContain('Branch:'); + expect(block).toContain('Remote: https://github.com/acme/widgets.git'); + expect(block).toContain('Recent commits:'); + }); + it('treats a hanging git command as a failure (timeout)', async () => { vi.useFakeTimers(); try { From dd580d2d7a949672bc6929ba58e77a8310c486a4 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 24 Jun 2026 19:39:18 +0800 Subject: [PATCH 3/3] fix(agent-core): show whatever git info is available in explore context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git probes fail in perfectly normal states — no `origin` remote, no commits yet (unborn branch), detached HEAD, older Git — so a failed probe no longer aborts the whole collection. Each probe is now best-effort: failures are logged and their section is omitted, and the block is dropped only when nothing useful was collected. Branch is read via `symbolic-ref --short HEAD`, which works in unborn repositories and on older Git; it fails in detached-HEAD state, where the Branch section is just omitted. --- .../agent-core/src/session/git-context.ts | 54 ++++++------------- .../test/session/git-context.test.ts | 16 +++--- 2 files changed, 27 insertions(+), 43 deletions(-) diff --git a/packages/agent-core/src/session/git-context.ts b/packages/agent-core/src/session/git-context.ts index cae21547a9..7ba1c968bc 100644 --- a/packages/agent-core/src/session/git-context.ts +++ b/packages/agent-core/src/session/git-context.ts @@ -3,13 +3,11 @@ * * `collectGitContext` produces a `` block that is prepended to a * fresh explore subagent's prompt so it can orient itself in the repository - * before searching. The block is all-or-nothing for the probes that can - * mislead: if `git status` or `git branch` fails, the whole block is dropped - * (and logged) rather than showing a partial snapshot — e.g. a timed-out - * `git status` would make a dirty tree look clean. Optional probes — the - * `origin` remote and recent commits — degrade to an empty section when - * unavailable, since a missing remote or no commits yet are normal repo - * states. The only explicit state surfaced to the subagent is + * before searching. Every git probe is best-effort: probes fail in perfectly + * normal states (no `origin` remote, no commits yet, detached HEAD, older + * Git), so a failed probe is logged and its section omitted rather than + * dropping the whole block. The block is omitted entirely only when nothing + * useful was collected. The one explicit state surfaced to the subagent is * `reason="not-a-repo"`, so it doesn't waste turns probing git history in a * non-repo directory. Remote URLs are sanitized so internal infrastructure * is not surfaced to the model. @@ -68,22 +66,18 @@ export async function collectGitContext(kaos: Kaos, cwd: string): Promise ({ args, result: await runGit(kaos, cwd, args) })), )) as unknown as [TaggedGitResult, TaggedGitResult, TaggedGitResult, TaggedGitResult]; - const fatal = [branch, status].filter(({ result }) => !result.ok); - if (fatal.length > 0) { - for (const { args, result } of fatal) { - if (!result.ok) logGitFailure(cwd, args, result); - } - return ''; - } - - for (const { args, result } of [remote, gitLog]) { - if (!result.ok) { - log.debug('git context optional probe unavailable', { - cwd, - command: `git ${args.join(' ')}`, - reason: result.kind, - }); - } + for (const { args, result } of [remote, branch, status, gitLog]) { + if (!result.ok) logGitFailure(cwd, args, result); } const remoteUrl = stdoutOf(remote.result); @@ -127,7 +107,7 @@ export async function collectGitContext(kaos: Kaos, cwd: string): Promise line.trim().length > 0); if (dirtyLines.length > 0) { diff --git a/packages/agent-core/test/session/git-context.test.ts b/packages/agent-core/test/session/git-context.test.ts index 81263747eb..cb135daa88 100644 --- a/packages/agent-core/test/session/git-context.test.ts +++ b/packages/agent-core/test/session/git-context.test.ts @@ -75,7 +75,7 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: 'https://github.com/acme/widgets.git' }, - 'rev-parse --abbrev-ref HEAD': { stdout: 'main' }, + 'symbolic-ref --short HEAD': { stdout: 'main' }, status: { stdout: ' M src/a.ts\n?? src/b.ts' }, log: { stdout: 'abc123 first commit\ndef456 second commit' }, }); @@ -99,7 +99,7 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: '' }, - 'rev-parse --abbrev-ref HEAD': { stdout: '' }, + 'symbolic-ref --short HEAD': { stdout: '' }, status: { stdout: dirty }, log: { stdout: '' }, }); @@ -119,7 +119,7 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: 'git@internal.corp:secret/repo.git' }, - 'rev-parse --abbrev-ref HEAD': { stdout: 'main' }, + 'symbolic-ref --short HEAD': { stdout: 'main' }, status: { stdout: '' }, log: { stdout: '' }, }); @@ -136,7 +136,7 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: '', exitCode: 2, stderr: "error: No such remote 'origin'" }, - 'rev-parse --abbrev-ref HEAD': { stdout: 'main' }, + 'symbolic-ref --short HEAD': { stdout: 'main' }, status: { stdout: ' M src/a.ts' }, log: { stdout: 'abc123 first commit' }, }); @@ -154,7 +154,7 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: 'https://github.com/acme/widgets.git' }, - 'rev-parse --abbrev-ref HEAD': { stdout: 'main' }, + 'symbolic-ref --short HEAD': { stdout: 'main' }, status: { stdout: '' }, log: { stdout: '', @@ -174,7 +174,11 @@ describe('collectGitContext', () => { it('omits the Branch section in detached HEAD state', async () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, - 'rev-parse --abbrev-ref HEAD': { stdout: 'HEAD' }, + 'symbolic-ref --short HEAD': { + stdout: '', + exitCode: 128, + stderr: 'fatal: ref HEAD is not a symbolic ref', + }, remote: { stdout: 'https://github.com/acme/widgets.git' }, status: { stdout: '' }, log: { stdout: 'abc123 first commit' },