From 0522b5962d186e57836e96c86b42dc8b8414aae2 Mon Sep 17 00:00:00 2001 From: arumata Date: Thu, 3 Sep 2026 10:47:31 +0300 Subject: [PATCH 1/2] fix(branches): drop the detached-HEAD pseudo-branch git branch -a --format lists the detached state as a pseudo-branch whose %(refname) is "(HEAD detached at )" instead of a refs/... path. parseBranches surfaced it as a real local branch, which showed up in the branch list and produced an invalid "(HEAD detached ...)" entry in the reflog ref filter that failed with 'unknown revision' (issue #65). --- src/git/__tests__/git-parser.test.ts | 14 ++++++++++++-- src/git/git-parser.ts | 12 ++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/git/__tests__/git-parser.test.ts b/src/git/__tests__/git-parser.test.ts index 321a0ed..e47271c 100644 --- a/src/git/__tests__/git-parser.test.ts +++ b/src/git/__tests__/git-parser.test.ts @@ -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); @@ -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); @@ -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 )"; 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". diff --git a/src/git/git-parser.ts b/src/git/git-parser.ts index 9c7db9e..3a6e775 100644 --- a/src/git/git-parser.ts +++ b/src/git/git-parser.ts @@ -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); @@ -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 )" 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); } From bd69572303a117742f23144c8c18659f6ded4a17 Mon Sep 17 00:00:00 2001 From: arumata Date: Thu, 3 Sep 2026 10:47:31 +0300 Subject: [PATCH 2/2] fix(log): include detached HEAD as a walk start point The log walk started only from --glob=refs/heads/remotes/tags, so a commit made in a detached HEAD was reachable from no ref and never appeared in the graph (issue #63). Add HEAD itself as a start point in the unfiltered case. --- src/git/__tests__/integration/basic.integration.test.ts | 9 +++++++++ src/git/git-service.ts | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/git/__tests__/integration/basic.integration.test.ts b/src/git/__tests__/integration/basic.integration.test.ts index 5c594ac..92ab50a 100644 --- a/src/git/__tests__/integration/basic.integration.test.ts +++ b/src/git/__tests__/integration/basic.integration.test.ts @@ -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', () => { diff --git a/src/git/git-service.ts b/src/git/git-service.ts index 71e6576..b48983f 100644 --- a/src/git/git-service.ts +++ b/src/git/git-service.ts @@ -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') {