mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user