Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 17 additions & 43 deletions apps/api/src/decofile/commit-coalescer.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -174,33 +170,24 @@ async function commitBatch(batch: Batch): Promise<string> {

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,
});
Expand All @@ -217,16 +204,3 @@ async function commitBatch(batch: Batch): Promise<string> {
}
}
}

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);
}
71 changes: 71 additions & 0 deletions apps/api/src/decofile/gen-artifact.ts
Original file line number Diff line number Diff line change
@@ -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<BlockSource>;
memo: Map<string, string>;
}): Promise<FileChange | null> {
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,
);
}
Loading