diff --git a/apps/api/src/decofile/commit-coalescer.ts b/apps/api/src/decofile/commit-coalescer.ts index e5b3df6a33..f0ba25411a 100644 --- a/apps/api/src/decofile/commit-coalescer.ts +++ b/apps/api/src/decofile/commit-coalescer.ts @@ -1,9 +1,5 @@ -import { - appendCoAuthorTrailer, - type CoAuthorIdentity, -} from "@decocms/sandbox/shared"; -import { blockKeyToFileStem, mergeBlocks } from "@decocms/shared/decofile"; -import { repoIdentityKey } from "@decocms/shared/git-providers"; +import type { CoAuthorIdentity } from "@decocms/sandbox/shared"; +import { blockKeyToFileStem } from "@decocms/shared/decofile"; import { exponentialBackoffWithJitter, sleep } from "@decocms/shared/std"; import { type FileChange, @@ -15,9 +11,9 @@ import { blockEntriesInTree, blocksDirPath, primeBlobCache, - resolveBlockContents, resolveOrCreateHead, } from "./read-decofile"; +import { decofileCommitMessage, regenerateGenArtifact } from "./gen-artifact"; /** * Per-(virtualMcpId, branch) commit coalescer. Autosaves arrive every ~700ms @@ -174,33 +170,24 @@ async function commitBatch(batch: Batch): Promise { if (writes.length === 0) return headSha; - // Repos that track the merged artifact get it regenerated in-commit; - // gitignored repos (the common case) never have the tree entry. - const genPath = packagePath - ? `${packagePath}/.deco/blocks.gen.json` - : ".deco/blocks.gen.json"; - if (tree.some((e) => e.type === "blob" && e.path === genPath)) { - const files = await resolveBlockContents( - client, - nextBlocks.values(), - blobMemo, - ); - const { decofile: genContent, skipped } = mergeBlocks(files); - if (skipped.length > 0) { - console.warn("decofile gen: dropped blocks that were not valid JSON", { - repo: repoIdentityKey(client.repo), - branch, - packagePath, - blocks: skipped.map((s) => s.key), - }); - } - writes.push({ path: genPath, content: genContent }); - } + const gen = await regenerateGenArtifact({ + client, + tree, + packagePath, + branch, + nextBlocks: nextBlocks.values(), + memo: blobMemo, + }); + if (gen) writes.push(gen); try { const { sha } = await client.commitFiles({ branch, - message: commitMessage(batch), + message: decofileCommitMessage( + [...batch.set.keys()], + [...batch.del], + batch.deps.coAuthor, + ), expectedHead: headSha, changes: writes, }); @@ -217,16 +204,3 @@ async function commitBatch(batch: Batch): Promise { } } } - -function commitMessage(batch: Batch): string { - const summarize = (keys: string[]): string => { - const shown = keys.slice(0, 3).join(", "); - return keys.length > 3 ? `${shown} (+${keys.length - 3} more)` : shown; - }; - const parts: string[] = []; - if (batch.set.size > 0) - parts.push(`update ${summarize([...batch.set.keys()])}`); - if (batch.del.size > 0) parts.push(`delete ${summarize([...batch.del])}`); - const subject = `chore(decofile): ${parts.join("; ")}`; - return appendCoAuthorTrailer(subject, batch.deps.coAuthor); -} diff --git a/apps/api/src/decofile/gen-artifact.ts b/apps/api/src/decofile/gen-artifact.ts new file mode 100644 index 0000000000..ed945e9043 --- /dev/null +++ b/apps/api/src/decofile/gen-artifact.ts @@ -0,0 +1,71 @@ +import { + appendCoAuthorTrailer, + type CoAuthorIdentity, +} from "@decocms/sandbox/shared"; +import { mergeBlocks } from "@decocms/shared/decofile"; +import { repoIdentityKey } from "@decocms/shared/git-providers"; +import type { FileChange, RepoContentClient, TreeEntry } from "@/git-providers"; +import { type BlockSource, resolveBlockContents } from "./read-decofile"; + +/** Repo-relative path of the merged `blocks.gen.json` artifact. */ +function genArtifactPath(packagePath: string | null): string { + return packagePath + ? `${packagePath}/.deco/blocks.gen.json` + : ".deco/blocks.gen.json"; +} + +/** + * The regenerated `blocks.gen.json`, for a commit that changes `.deco/blocks`. + * + * Repos that track the merged artifact get it rewritten in the same commit, so + * it never disagrees with the block files; gitignored repos (the common case) + * have no tree entry and get `null`. `nextBlocks` is the post-commit view of + * the blocks dir; `memo` is the blob memo a compare-and-swap retry loop + * threads through, so a retry never re-reads a blob it already resolved. + */ +export async function regenerateGenArtifact(params: { + client: RepoContentClient; + tree: TreeEntry[]; + packagePath: string | null; + branch: string; + nextBlocks: Iterable; + memo: Map; +}): Promise { + const { client, tree, packagePath, branch } = params; + const genPath = genArtifactPath(packagePath); + if (!tree.some((e) => e.type === "blob" && e.path === genPath)) return null; + const files = await resolveBlockContents( + client, + params.nextBlocks, + params.memo, + ); + const { decofile, skipped } = mergeBlocks(files); + if (skipped.length > 0) { + console.warn("decofile gen: dropped blocks that were not valid JSON", { + repo: repoIdentityKey(client.repo), + branch, + packagePath, + blocks: skipped.map((s) => s.key), + }); + } + return { path: genPath, content: decofile }; +} + +/** The commit message of a decofile write: what changed, plus the co-author. */ +export function decofileCommitMessage( + setKeys: string[], + deleteKeys: string[], + coAuthor: CoAuthorIdentity | null | undefined, +): string { + const summarize = (keys: string[]): string => { + const shown = keys.slice(0, 3).join(", "); + return keys.length > 3 ? `${shown} (+${keys.length - 3} more)` : shown; + }; + const parts: string[] = []; + if (setKeys.length > 0) parts.push(`update ${summarize(setKeys)}`); + if (deleteKeys.length > 0) parts.push(`delete ${summarize(deleteKeys)}`); + return appendCoAuthorTrailer( + `chore(decofile): ${parts.join("; ")}`, + coAuthor, + ); +}