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
14 changes: 12 additions & 2 deletions src/git/__tests__/git-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ describe('parseBranches', () => {
});

it('should parse current branch', () => {
const raw = '*main\x00abc1234\x00origin/main\x00ahead 2, behind 1';
const raw = '*main\x00abc1234\x00origin/main\x00ahead 2, behind 1\x00refs/heads/main';
const result = parseBranches(raw);

expect(result).toHaveLength(1);
Expand Down Expand Up @@ -179,7 +179,7 @@ describe('parseBranches', () => {
});

it('should parse non-current branch', () => {
const raw = ' feature\x00def5678\x00\x00';
const raw = ' feature\x00def5678\x00\x00\x00refs/heads/feature';
const result = parseBranches(raw);

expect(result).toHaveLength(1);
Expand All @@ -189,6 +189,16 @@ describe('parseBranches', () => {
expect(result[0].behind).toBe(0);
});

it('drops the detached-HEAD pseudo-branch', () => {
// git reports the detached state as a pseudo-branch whose %(refname) is
// "(HEAD detached at <hash>)"; it must not surface as a branch (issue #65).
const raw = '*(HEAD detached at 6f6a34c)\x006f6a34c\x00\x00\x00(HEAD detached at 6f6a34c)\n master\x00a2f05b3\x00\x00\x00refs/heads/master';
const result = parseBranches(raw);

expect(result).toHaveLength(1);
expect(result[0].name).toBe('master');
});

it('flags upstreamGone when the tracked remote branch was deleted', () => {
// git keeps the upstream config after the remote branch is deleted and
// reports the track field as "gone".
Expand Down
9 changes: 9 additions & 0 deletions src/git/__tests__/integration/basic.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ describe('GitService integration — basic queries', () => {
expect(subjects).toContain('init');
expect(subjects).not.toContain('m2');
});

it('includes a commit reachable only from a detached HEAD', async () => {
const base = commit(repo.path, 'base', { 'a.txt': '1\n' });
runGit(repo.path, ['checkout', '--detach', base]);
const detached = commit(repo.path, 'detached', { 'a.txt': '2\n' });

const commits = await svc.log();
expect(commits.map((c) => c.hash)).toContain(detached);
});
});

describe('branches', () => {
Expand Down
12 changes: 10 additions & 2 deletions src/git/git-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export function parseBranches(raw: string): BranchInfo[] {
return [];
}

return raw.trim().split('\n').filter(Boolean).map((line) => {
return raw.trim().split('\n').filter(Boolean).flatMap((line) => {
const current = line.startsWith('*');
const rest = current ? line.substring(1) : line;
const fields = rest.split(FIELD_SEP);
Expand All @@ -153,12 +153,20 @@ export function parseBranches(raw: string): BranchInfo[] {

// Use full refname to distinguish local from remote branches
const fullRefname = fields[4]?.trim() ?? '';
// In a detached HEAD git lists a pseudo-branch whose %(refname) is
// "(HEAD detached at <hash>)" instead of a refs/... path. It is not a real
// branch and must not surface as one (it produced an invalid "(HEAD
// detached ...)" entry in the reflog ref filter, issue #65). A missing
// (empty) refname is kept as a defensive fallback for older git formats.
if (fullRefname && !fullRefname.startsWith('refs/')) {
return [];
}
const isRemote = fullRefname.startsWith('refs/remotes/');
const remote = isRemote ? rawName.split('/')[0] : undefined;
// Strip heads/ prefix added by git when tag and branch names collide
const name = !isRemote && rawName.startsWith('heads/') ? rawName.substring(6) : rawName;

return { name, current, remote, upstream, upstreamGone, ahead, behind, hash };
return [{ name, current, remote, upstream, upstreamGone, ahead, behind, hash }];
}).filter(b => b.name.length > 0);
}

Expand Down
5 changes: 4 additions & 1 deletion src/git/git-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,10 @@ export class GitService {
args.push(branch);
}
} else if (!options?.remoteFilter || options.remoteFilter.length === 0) {
args.push('--glob=refs/heads', '--glob=refs/remotes', '--glob=refs/tags');
// Include HEAD itself as a start point: in a detached HEAD the current
// commit is reachable from no branch/tag, so the globs alone would omit
// it entirely (issue #63).
args.push('--glob=refs/heads', '--glob=refs/remotes', '--glob=refs/tags', 'HEAD');
} else {
for (const source of options.remoteFilter) {
if (source === 'local') {
Expand Down
Loading