From 3ca6150a98e255a397a9b582f5a1a900beeb9381 Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Tue, 5 May 2026 10:24:49 -0400 Subject: [PATCH] fix(commit): tolerate initial-commit repos in branch-name lookup (#844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `coco commit` ran the full diff pipeline (collect → pre-process → consolidate → generate) and then crashed with "fatal: ambiguous argument 'HEAD'" when the post-summary step asked for the current branch via `git rev-parse --abbrev-ref HEAD`. On an initial-commit repo (fresh `git init`, no commits yet), HEAD doesn't resolve via rev-parse and the call fails fatally — but by that point the pipeline has already burned several minutes of LLM calls summarizing the staged changes, so the user pays the full cost and gets nothing back. `git symbolic-ref --short HEAD` still reports the configured initial branch name even with no commits, so getCurrentBranchName now falls through to it on rev-parse failure and to an empty string only if both calls fail. Every caller already handles an empty branch context as "no branch info", so the no-HEAD case now runs the commit message generation to completion instead of aborting after the heavy lifting is done. Closes #844. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../simple-git/getCurrentBranchName.test.ts | 75 +++++++++++++------ src/lib/simple-git/getCurrentBranchName.ts | 30 ++++++-- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/src/lib/simple-git/getCurrentBranchName.test.ts b/src/lib/simple-git/getCurrentBranchName.test.ts index 8637f14e..032c2078 100644 --- a/src/lib/simple-git/getCurrentBranchName.test.ts +++ b/src/lib/simple-git/getCurrentBranchName.test.ts @@ -1,27 +1,60 @@ -import { simpleGit, SimpleGit } from 'simple-git'; -import { getCurrentBranchName } from './getCurrentBranchName'; +import { SimpleGit } from 'simple-git' +import { getCurrentBranchName } from './getCurrentBranchName' -jest.mock('simple-git', () => ({ - simpleGit: jest.fn().mockImplementation(() => ({ - branch: jest.fn().mockResolvedValue({ current: 'main' }), - revparse: jest.fn().mockResolvedValue('main'), - })), -})); +function mockGit(overrides: Partial<{ + revparse: jest.Mock + raw: jest.Mock +}> = {}): SimpleGit { + return { + revparse: overrides.revparse || jest.fn().mockResolvedValue('main'), + raw: overrides.raw || jest.fn(), + } as unknown as SimpleGit +} describe('getCurrentBranchName', () => { - let git: SimpleGit; + it('returns the current branch name from rev-parse on a normal repo', async () => { + const git = mockGit() + expect(await getCurrentBranchName({ git })).toBe('main') + expect(git.revparse).toHaveBeenCalledWith(['--abbrev-ref', 'HEAD']) + }) - beforeEach(() => { - git = simpleGit(); - }); + // #844 — `git rev-parse --abbrev-ref HEAD` fails fatally on a fresh + // `git init` repo with no commits yet. `git symbolic-ref --short + // HEAD` still reports the configured initial branch in that state, + // so the helper falls through to it instead of crashing the + // entire commit pipeline (which has already run for minutes by + // the time this branch lookup fires). + it('falls back to symbolic-ref when rev-parse fails (initial-commit repo)', async () => { + const git = mockGit({ + revparse: jest.fn().mockRejectedValue( + new Error("fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.") + ), + raw: jest.fn().mockResolvedValue('main\n'), + }) + expect(await getCurrentBranchName({ git })).toBe('main') + expect(git.raw).toHaveBeenCalledWith(['symbolic-ref', '--short', 'HEAD']) + }) - it('should return the current branch name', async () => { - const branchName = await getCurrentBranchName({ git }); - expect(branchName).toBe('main'); - }); + it('trims trailing whitespace from the symbolic-ref fallback output', async () => { + const git = mockGit({ + revparse: jest.fn().mockRejectedValue(new Error('boom')), + raw: jest.fn().mockResolvedValue(' main \n'), + }) + expect(await getCurrentBranchName({ git })).toBe('main') + }) - it('should handle errors gracefully', async () => { - git.revparse = jest.fn().mockRejectedValue(new Error('Git error')); - await expect(getCurrentBranchName({ git })).rejects.toThrow('Git error'); - }); -}); + it('returns an empty string when both rev-parse and symbolic-ref fail', async () => { + const git = mockGit({ + revparse: jest.fn().mockRejectedValue(new Error('rev-parse blew up')), + raw: jest.fn().mockRejectedValue(new Error('symbolic-ref blew up')), + }) + expect(await getCurrentBranchName({ git })).toBe('') + }) + + it('does not call symbolic-ref when rev-parse succeeds', async () => { + const raw = jest.fn() + const git = mockGit({ raw }) + await getCurrentBranchName({ git }) + expect(raw).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/simple-git/getCurrentBranchName.ts b/src/lib/simple-git/getCurrentBranchName.ts index d236840a..7a803e5d 100644 --- a/src/lib/simple-git/getCurrentBranchName.ts +++ b/src/lib/simple-git/getCurrentBranchName.ts @@ -5,11 +5,31 @@ export type GetCurrentBranchName = { } /** - * Retrieves the name of the current branch. - * - * @param {GetCurrentBranchName} options - The options for retrieving the branch name. - * @returns {Promise} - A promise that resolves to the name of the current branch. + * Retrieve the name of the current branch. + * + * The first-choice path uses `git rev-parse --abbrev-ref HEAD`, which + * returns the active branch on a normal repo. On an initial-commit + * repo (fresh `git init` with no commits yet) HEAD does not resolve + * and rev-parse fails fatally — but `git symbolic-ref --short HEAD` + * still reports the configured initial branch name, so we fall + * through to that. Final fallback is an empty string for genuinely + * detached / corrupt states; every caller treats that as "no branch + * context", which is the right semantics for a no-HEAD repo. + * + * Without this resilience, every command that depends on the branch + * name (e.g. the post-summary step in `coco commit`) would crash + * with `fatal: ambiguous argument 'HEAD'` after the entire diff + * pipeline already ran (#844). */ export async function getCurrentBranchName({ git }: GetCurrentBranchName): Promise { - return await git.revparse(['--abbrev-ref', 'HEAD']) + try { + return await git.revparse(['--abbrev-ref', 'HEAD']) + } catch { + try { + const ref = await git.raw(['symbolic-ref', '--short', 'HEAD']) + return ref.trim() + } catch { + return '' + } + } }