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:
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user