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: string[] = [] let messageStart = 0 for (let field = 0; field < 8; field += 1) { const newline = record.indexOf('\n', messageStart) if (newline === -1) { lines.push(record.slice(messageStart)) messageStart = record.length break } lines.push(record.slice(messageStart, newline)) messageStart = newline + 1 } 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 = record.slice(messageStart).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' } }