fix(git): recover commit ref badges on Git older than 2.43 (#17923)

GIT_HISTORY_COMMIT_FORMAT asked for decorations with %(decorate:…), which
Git 2.43 introduced. Older Git prints the placeholder verbatim and exits
zero, so nothing raised and every commit in the Source Control panel
silently lost its branch, remote and tag badges.

The record now also carries %D (Git 2.10) on its own line, selected by an
exact match against the unexpanded placeholder — a ref name can never
contain the \x1f that Git expands inside the echoed text. %n emits the %D
line on both sides of the boundary, so the message index is fixed and a
missed match degrades to no badges rather than a corrupted message.

The decoration separator is now bound to the field that produced the text
instead of sniffed from it. A lone decoration carries no separator, so the
old sniff split `refs/heads/feat,one` into two bogus refs.

Verified against real Git 2.38.1 and 2.49.1.

Co-authored-by: kaluli123123 <295758798+kaluli123123@users.noreply.github.com>
This commit is contained in:
Neil
2026-09-01 17:14:57 -07:00
committed by GitHub
co-authored by kaluli123123
parent c8937936eb
commit 80a52bb9b3
4 changed files with 102 additions and 12 deletions
+11
View File
@@ -42,6 +42,17 @@ authority.
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
### Placeholders That Fail Open
`GitCapabilityCache` records commands Git *rejects*. A `git log --format`
placeholder Git does not know is not rejected: Git echoes it verbatim and exits
zero, so there is no error to remember and no probe to cache. Ask for both forms
in one record and pick at parse time.
| Placeholder | Preferred behavior | Compatibility behavior |
| ---------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `%(decorate:…)` | Git 2.43 separates commit decorations with `\x1f`, so ref names containing commas survive | The same record also carries `%D` (Git 2.10); an unexpanded `%(decorate` placeholder selects it, at the cost of comma-splitting |
## Why Not `simple-git`
`simple-git` is a process wrapper around the installed Git binary. Its custom
@@ -15,6 +15,7 @@ import {
isUnsupportedWorktreeListZError
} from './git-worktree-command-capabilities'
import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env'
import { GIT_HISTORY_COMMIT_FORMAT, parseGitHistoryLog } from './git-history-log-parser'
import {
githubPullRequestHeadLocalRef,
gitlabMergeRequestHeadLocalRef,
@@ -378,4 +379,29 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
runGit(['show', '--end-of-options', `${pinnedOid}:absent.txt`])
).rejects.toBeDefined()
})
// Why pin this: an older Git echoes %(decorate:…) and exits zero, so only %D
// in the same record carries the badges (#15507). Asserts the echo and the recovery.
it('reads commit decorations on both sides of the %(decorate:...) boundary', async () => {
await writeFile(join(repoPath, 'decorated.txt'), 'decorated\n')
await runGit(['add', 'decorated.txt'])
await runGit(['commit', '-qm', 'decorated commit'])
await runGit(['tag', 'compat-decorated'])
const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim()
const log = await runGit([
'log',
`--format=${GIT_HISTORY_COMMIT_FORMAT}`,
'-z',
'--decorate=full',
'-n1',
head
])
expect(log.stdout.includes('%(decorate')).toBe(!supports(2, 43))
const [item] = parseGitHistoryLog(log.stdout)
expect(item?.id).toBe(head)
expect(item?.subject).toBe('decorated commit')
expect(item?.references?.map((ref) => ref.id)).toContain('refs/tags/compat-decorated')
})
})
+23 -10
View File
@@ -2,9 +2,15 @@ import type { GitHistoryItem, GitHistoryItemRef } from './git-history-types'
import { iterateNulDelimitedFields } from './nul-delimited-fields'
const GIT_HISTORY_DECORATION_SEPARATOR = '\x1f'
const GIT_HISTORY_LEGACY_DECORATION_SEPARATOR = ','
// Why %D too: %(decorate:…) is Git 2.43+, and older Git echoes it verbatim and exits zero.
// Callers must pass --decorate=full; both fields emit short names otherwise, which parse to no refs.
export const GIT_HISTORY_COMMIT_FORMAT =
'%H%n%aN%n%aE%n%at%n%ct%n%P%n%(decorate:prefix=,suffix=,separator=%x1f)%n%B'
'%H%n%aN%n%aE%n%at%n%ct%n%P%n%(decorate:prefix=,suffix=,separator=%x1f)%n%D%n%B'
// Why exact-match: no ref name may contain the \x1f an old Git echoes here.
const UNEXPANDED_DECORATE_PLACEHOLDER = `%(decorate:prefix=,suffix=,separator=${GIT_HISTORY_DECORATION_SEPARATOR})`
export function shortGitHash(hash: string): string {
return hash.slice(0, 7)
@@ -15,17 +21,18 @@ function commitSubject(message: string): string {
return firstLine || '(no commit message)'
}
function parseGitDecorationRefs(raw: string, revision: string): GitHistoryItemRef[] {
function parseGitDecorationRefs(
raw: string,
revision: string,
separator: string
): GitHistoryItemRef[] {
if (!raw.trim()) {
return []
}
const refs: GitHistoryItemRef[] = []
// Why: Git permits commas in ref names, so Orca's git log format uses a
// control-character separator that Git ref names cannot contain.
const parts = raw.includes(GIT_HISTORY_DECORATION_SEPARATOR)
? raw.split(GIT_HISTORY_DECORATION_SEPARATOR)
: raw.split(',')
// Why passed in: a lone decoration carries no separator, so sniffing `raw` split `feat,one`.
const parts = raw.split(separator)
for (const part of parts) {
const ref = part.trim()
@@ -115,8 +122,10 @@ export function parseGitHistoryLog(stdout: string): GitHistoryItem[] {
const authorEmail = lines[2] ?? ''
const authorDateSeconds = Number.parseInt(lines[3] ?? '', 10)
const parents = (lines[5] ?? '').trim()
const decorations = lines[6] ?? ''
const message = lines.slice(7).join('\n').replace(/\n$/, '')
const decorateField = lines[6] ?? ''
const isLegacyGit = decorateField === UNEXPANDED_DECORATE_PLACEHOLDER
const decorations = isLegacyGit ? (lines[7] ?? '') : decorateField
const message = lines.slice(8).join('\n').replace(/\n$/, '')
items.push({
id: hash,
@@ -127,7 +136,11 @@ export function parseGitHistoryLog(stdout: string): GitHistoryItem[] {
authorEmail: authorEmail || undefined,
displayId: shortGitHash(hash),
timestamp: Number.isFinite(authorDateSeconds) ? authorDateSeconds * 1000 : undefined,
references: parseGitDecorationRefs(decorations, hash)
references: parseGitDecorationRefs(
decorations,
hash,
isLegacyGit ? GIT_HISTORY_LEGACY_DECORATION_SEPARATOR : GIT_HISTORY_DECORATION_SEPARATOR
)
})
}
return items
+42 -2
View File
@@ -16,6 +16,7 @@ function logRecord({
hash,
parents = [],
decorations = '',
legacyDecorations = '',
message,
author = 'Ada Lovelace',
timestamp = 1_700_000_000
@@ -23,6 +24,7 @@ function logRecord({
hash: string
parents?: string[]
decorations?: string
legacyDecorations?: string
message: string
author?: string
timestamp?: number
@@ -35,6 +37,7 @@ function logRecord({
String(timestamp),
parents.join(' '),
decorations,
legacyDecorations,
message
].join('\n')}\0`
}
@@ -94,8 +97,12 @@ describe('git history parsing', () => {
const stdout = logRecord({
hash: HEAD_OID,
parents: [BASE_OID],
decorations:
'HEAD -> refs/heads/feature, refs/remotes/origin/HEAD -> refs/remotes/origin/feature, refs/remotes/origin/feature, tag: refs/tags/v1.0.0',
decorations: [
'HEAD -> refs/heads/feature',
'refs/remotes/origin/HEAD -> refs/remotes/origin/feature',
'refs/remotes/origin/feature',
'tag: refs/tags/v1.0.0'
].join(DECORATION_SEPARATOR),
message: 'feat: add graph\n\nbody line'
})
@@ -117,6 +124,39 @@ describe('git history parsing', () => {
])
})
it('falls back to %D decorations when Git predates the %(decorate:…) placeholder', () => {
// Why: Git < 2.43 echoes the placeholder and exits zero (#15507).
const stdout = logRecord({
hash: HEAD_OID,
decorations: `%(decorate:prefix=,suffix=,separator=${DECORATION_SEPARATOR})`,
legacyDecorations: 'HEAD -> refs/heads/feature, tag: refs/tags/v1.0.0',
message: 'feat: add graph'
})
const [item] = parseGitHistoryLog(stdout)
expect(item?.subject).toBe('feat: add graph')
expect(item?.references?.map((ref) => [ref.id, ref.name, ref.category])).toEqual([
['refs/heads/feature', 'feature', 'branches'],
['refs/tags/v1.0.0', 'v1.0.0', 'tags']
])
})
it('keeps a comma inside a lone decoration, which carries no separator', () => {
// Why: a lone decoration carries no separator, so sniffing for \x1f split it in two.
const stdout = logRecord({
hash: HEAD_OID,
decorations: 'HEAD -> refs/heads/feat,one',
message: 'initial'
})
const [item] = parseGitHistoryLog(stdout)
expect(item?.references?.map((ref) => [ref.id, ref.name])).toEqual([
['refs/heads/feat,one', 'feat,one']
])
})
it('preserves commas inside branch and tag decoration names', () => {
const stdout = logRecord({
hash: HEAD_OID,