Files
orca/src/shared/git-history-log-parser.ts
T
Neilandkaluli123123 80a52bb9b3 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>
2026-09-01 17:14:57 -07:00

166 lines
4.9 KiB
TypeScript

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%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)
}
function commitSubject(message: string): string {
const firstLine = message.split(/\r?\n/, 1)[0]?.trim()
return firstLine || '(no commit message)'
}
function parseGitDecorationRefs(
raw: string,
revision: string,
separator: string
): GitHistoryItemRef[] {
if (!raw.trim()) {
return []
}
const refs: GitHistoryItemRef[] = []
// 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()
if (!ref || ref === 'HEAD' || /^refs\/remotes\/[^/]+\/HEAD(?:\s|$)/.test(ref)) {
continue
}
if (ref.startsWith('HEAD -> refs/heads/')) {
refs.push({
id: ref.slice('HEAD -> '.length),
name: ref.slice('HEAD -> refs/heads/'.length),
revision,
category: 'branches'
})
continue
}
if (ref.startsWith('refs/heads/')) {
refs.push({
id: ref,
name: ref.slice('refs/heads/'.length),
revision,
category: 'branches'
})
continue
}
if (ref.startsWith('refs/remotes/')) {
refs.push({
id: ref,
name: ref.slice('refs/remotes/'.length),
revision,
category: 'remote branches'
})
continue
}
if (ref.startsWith('tag: refs/tags/')) {
refs.push({
id: ref.slice('tag: '.length),
name: ref.slice('tag: refs/tags/'.length),
revision,
category: 'tags'
})
}
}
return refs.sort(compareGitHistoryItemRefsByCategory)
}
export function compareGitHistoryItemRefsByCategory(
ref1: GitHistoryItemRef,
ref2: GitHistoryItemRef
): number {
const order = (ref: GitHistoryItemRef): number => {
if (ref.id.startsWith('refs/heads/')) {
return 1
}
if (ref.id.startsWith('refs/remotes/')) {
return 2
}
if (ref.id.startsWith('refs/tags/')) {
return 3
}
return 99
}
const categoryOrder = order(ref1) - order(ref2)
return categoryOrder || ref1.name.localeCompare(ref2.name)
}
export function parseGitHistoryLog(stdout: string): GitHistoryItem[] {
const items: GitHistoryItem[] = []
for (const rawRecord of iterateNulDelimitedFields(stdout)) {
const record = rawRecord.replace(/^\n+/, '')
if (!record.trim()) {
continue
}
const lines = record.split('\n')
const hash = lines[0]?.trim() ?? ''
if (!/^[0-9a-fA-F]{40,64}$/.test(hash)) {
continue
}
const authorName = lines[1] ?? ''
const authorEmail = lines[2] ?? ''
const authorDateSeconds = Number.parseInt(lines[3] ?? '', 10)
const parents = (lines[5] ?? '').trim()
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,
parentIds: parents ? parents.split(' ') : [],
subject: commitSubject(message),
message,
author: authorName || undefined,
authorEmail: authorEmail || undefined,
displayId: shortGitHash(hash),
timestamp: Number.isFinite(authorDateSeconds) ? authorDateSeconds * 1000 : undefined,
references: parseGitDecorationRefs(
decorations,
hash,
isLegacyGit ? GIT_HISTORY_LEGACY_DECORATION_SEPARATOR : GIT_HISTORY_DECORATION_SEPARATOR
)
})
}
return items
}
export function gitHistoryRefFromFullName(
fullName: string | null,
fallbackName: string,
revision: string
): GitHistoryItemRef {
const id = fullName || fallbackName
if (id.startsWith('refs/heads/')) {
return { id, name: id.slice('refs/heads/'.length), revision, category: 'branches' }
}
if (id.startsWith('refs/remotes/')) {
return { id, name: id.slice('refs/remotes/'.length), revision, category: 'remote branches' }
}
if (id.startsWith('refs/tags/')) {
return { id, name: id.slice('refs/tags/'.length), revision, category: 'tags' }
}
return { id, name: fallbackName || shortGitHash(revision), revision, category: 'commits' }
}