Skip to content
Merged
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
75 changes: 54 additions & 21 deletions src/lib/simple-git/getCurrentBranchName.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
30 changes: 25 additions & 5 deletions src/lib/simple-git/getCurrentBranchName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>} - 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<string> {
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 ''
}
}
}
Loading