fix(blago-cli): pull не плодит ложные merge-конфликты при bump updated_at

Корневая причина: при pull конфликт объявлялся по условию
`dirty && remoteUpdatedAt !== prev.remote_updated_at`, где `dirty`
определяется сравнением raw-байтового sha файла с content_etag_local.
Серверные метки времени (updated_at/created_at) входят и во frontmatter
файла, и в etag. Когда сервер бьёт _updated_at родителя при дочерней
мутации (создание/удаление issue/story), etag в индексе расходится с
файлом ровно на строку updated_at — файл считается «грязным», а при
изменившемся remote_updated_at pull пишет маркеры слияния на весь файл,
хотя содержательно текст идентичен. Подтверждено: подстановка
remote_updated_at в файл воспроизводит etag байт-в-байт (58 записей на
проде voskhod).

Фикс: в syncEntityFile перед записью маркеров сравниваем локальный и
серверный тексты в каноне без updated_at/created_at. Если совпадают —
это не конфликт: принимаем серверную версию и лечим etag, без маркеров.
Реальная правка тела/заголовка по-прежнему даёт маркеры.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 12:00:43 +05:00
parent a93022343c
commit a0e169e9b4
@@ -4,11 +4,34 @@ import type { BlagoEntityType, IndexFile } from './index-store.js'
import * as fs from 'node:fs/promises' import * as fs from 'node:fs/promises'
import * as path from 'node:path' import * as path from 'node:path'
import { parseBlagoMarkdown, serializeBlagoMarkdown } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js' import { sha256Hex } from '../lib/hash.js'
import { warn } from '../ui/output.js' import { warn } from '../ui/output.js'
import { findByHash, normalizeRelativePath, upsertEntry } from './index-store.js' import { findByHash, normalizeRelativePath, upsertEntry } from './index-store.js'
/**
* Канонический вид для сравнения «есть ли реальный конфликт», без волатильных
* серверных меток времени. updated_at/created_at сервер бьёт при дочерних мутациях
* (создание/удаление issue/story у родителя), и они попадают и во frontmatter, и в
* content_etag_local. Из-за этого raw-sha файла расходится с etag, локальный файл
* считается «грязным», а pull при изменившемся remote_updated_at пишет маркеры слияния —
* хотя содержательно текст совпадает. Сравнение в каноне (без updated_at/created_at)
* отличает настоящую правку от чистого bump'а времени.
*/
function canonicalForCompare(raw: string): string {
try {
const parsed = parseBlagoMarkdown(raw)
const data = { ...parsed.data }
delete data.updated_at
delete data.created_at
return serializeBlagoMarkdown(data, parsed.body)
}
catch {
return raw
}
}
async function ensureDirForFile(absFile: string): Promise<void> { async function ensureDirForFile(absFile: string): Promise<void> {
await fs.mkdir(path.dirname(absFile), { recursive: true }) await fs.mkdir(path.dirname(absFile), { recursive: true })
} }
@@ -139,6 +162,21 @@ export async function syncEntityFile(params: {
const dirty const dirty
= current !== null && current !== undefined && sha256Hex(current) !== prev.content_etag_local = current !== null && current !== undefined && sha256Hex(current) !== prev.content_etag_local
if (dirty && remoteUpdatedAt !== prev.remote_updated_at) { if (dirty && remoteUpdatedAt !== prev.remote_updated_at) {
// Реальный конфликт — только если содержимое расходится вне волатильных меток времени.
// Если локальный и серверный тексты совпадают по канону (отличие лишь в updated_at/created_at),
// это не конфликт: принимаем серверную версию и лечим etag, без маркеров слияния.
if (current !== null && current !== undefined && canonicalForCompare(current) === canonicalForCompare(content)) {
await ensureDirForFile(absNew)
await fs.writeFile(absNew, content, 'utf8')
upsertEntry(index, {
entity_type: entityType,
entity_hash: entityHash,
relative_path: rel,
remote_updated_at: remoteUpdatedAt,
content_etag_local: sha256Hex(await fs.readFile(absNew, 'utf8')),
})
return
}
const merged = wrapMergeConflictMarkers(current ?? '', content) const merged = wrapMergeConflictMarkers(current ?? '', content)
await ensureDirForFile(absNew) await ensureDirForFile(absNew)
await fs.writeFile(absNew, merged, 'utf8') await fs.writeFile(absNew, merged, 'utf8')