Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/transaction-batch-spawns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@contentrain/mcp": patch
---

Speed up local writes by batching git subprocess spawns in the worktree transaction

Every local write (`content_save`, `model_save`, `bulk`, normalize apply, …) runs through `createTransaction`, which spawned ~40 `git` processes for a single content save. Two behavior-preserving batches cut that:

- **Commit identity via environment** — the worktree git instance now carries `GIT_AUTHOR_*` / `GIT_COMMITTER_*` in its env instead of running two `git config user.*` spawns per transaction (and per `contentrain_merge`). Git honors these for both the sync merges and the feature-branch commit.
- **Batched selective sync** — `selectiveSync` replaced its per-file `git cat-file -e` existence loop with a single `git ls-tree` over the changed paths, and its per-file `git checkout HEAD -- <file>` loop with a single batched checkout (per-file fallback on error preserves precise skip accounting). Working-tree deletions are parallel fs removals.

Measured: a 2-locale `content_save` drops from ~40 git spawns to ~30 (config 2→0, cat-file 5→0, checkout 7→3), roughly halving the operation's wall-clock. The selective-sync win scales with file count, so bulk writes benefit the most. No behavior change — the full worktree-transaction test suite passes unchanged.
114 changes: 75 additions & 39 deletions packages/mcp/src/git/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,25 @@ export async function ensureContentBranch(projectRoot: string): Promise<void> {
}
}

/**
* Commit identity for worktree operations, supplied via environment instead
* of two `git config` spawns per transaction. Git honors GIT_AUTHOR_* /
* GIT_COMMITTER_* for both the sync merges and the feature-branch commit, so
* a worktree git built with this env needs no `git config user.*` calls.
* `process.env` is spread so PATH/HOME and any GIT_* already set survive.
*/
function authorEnv(): Record<string, string | undefined> {
const name = process.env['CONTENTRAIN_AUTHOR_NAME'] ?? 'Contentrain'
const email = process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? '[email protected]'
return {
...process.env,
GIT_AUTHOR_NAME: name,
GIT_AUTHOR_EMAIL: email,
GIT_COMMITTER_NAME: name,
GIT_COMMITTER_EMAIL: email,
}
}

async function selectiveSync(
projectRoot: string,
_worktreePath: string,
Expand Down Expand Up @@ -99,40 +118,66 @@ async function selectiveSync(
// didn't touch them. We use the pre-update state to know what was truly dirty.
const dirtyFiles = dirtyFilesBeforeUpdate ?? new Set<string>()

// Determine which changed files still exist in contentrainTip (HEAD after update-ref)
// Check each changed file individually — `ls-tree` on specific paths is efficient
// Which changed files still exist in contentrainTip (HEAD after update-ref)?
// ONE `ls-tree` over the changed paths lists exactly the survivors, instead
// of a `cat-file -e` spawn per file. Falls back to per-file probing if
// ls-tree fails so behavior is preserved on any edge.
const filesInTip = new Set<string>()
try {
const lsOutput = await git.raw(['ls-tree', '-r', '--name-only', contentrainTip, '--', ...changedFiles])
for (const f of lsOutput.split('\n')) {
const trimmed = f.trim()
if (trimmed) filesInTip.add(trimmed)
}
} catch {
for (const file of changedFiles) {
try {
await git.raw(['cat-file', '-e', `${contentrainTip}:${file}`])
filesInTip.add(file)
} catch {
// File does not exist in tip (was deleted)
}
}
}

// Partition: dirty developer files are skipped; survivors get checked out
// from HEAD; the rest were deleted in the new HEAD and are removed on disk.
const toCheckout: string[] = []
const toRemove: string[] = []
for (const file of changedFiles) {
if (dirtyFiles.has(file)) skipped.push(file)
else if (filesInTip.has(file)) toCheckout.push(file)
else toRemove.push(file)
}

// ONE `git checkout HEAD -- f1 f2 …` restores every clean survivor at once.
// On failure, fall back to per-file so a single unresolvable path still
// yields precise skip accounting (dirty files were already excluded).
if (toCheckout.length > 0) {
try {
await git.raw(['cat-file', '-e', `${contentrainTip}:${file}`])
filesInTip.add(file)
await git.checkout(['HEAD', '--', ...toCheckout])
synced.push(...toCheckout)
} catch {
// File does not exist in tip (was deleted)
for (const file of toCheckout) {
try {
await git.checkout(['HEAD', '--', file])
synced.push(file)
} catch {
skipped.push(file)
}
}
}
}

for (const file of changedFiles) {
if (dirtyFiles.has(file)) {
// Deletions are working-tree fs removals — no git spawn, safe to parallelize.
await Promise.all(toRemove.map(async (file) => {
try {
await removeFile(join(projectRoot, file), { force: true })
synced.push(file)
} catch {
skipped.push(file)
} else if (filesInTip.has(file)) {
// File exists in new HEAD — checkout from HEAD
try {
await git.checkout(['HEAD', '--', file])
synced.push(file)
} catch {
skipped.push(file)
}
} else {
// File was deleted in new HEAD — remove from working tree
try {
const filePath = join(projectRoot, file)
await removeFile(filePath, { force: true })
synced.push(file)
} catch {
skipped.push(file)
}
}
}
}))

const warning = skipped.length > 0
? `${skipped.length} file(s) skipped due to local changes: ${skipped.join(', ')}. Commit your changes, then run: git checkout HEAD -- ${skipped.join(' ')}`
Expand Down Expand Up @@ -196,13 +241,9 @@ export async function createTransaction(
// Create worktree on contentrain branch
await git.raw(['worktree', 'add', worktreePath, CONTENTRAIN_BRANCH])

const wtGit = simpleGit(worktreePath)

// Configure author (sequential — git config uses lock file)
const authorName = process.env['CONTENTRAIN_AUTHOR_NAME'] ?? 'Contentrain'
const authorEmail = process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? '[email protected]'
await wtGit.addConfig('user.name', authorName)
await wtGit.addConfig('user.email', authorEmail)
// Commit identity comes from the environment (see authorEnv) — no
// `git config user.*` spawns.
const wtGit = simpleGit(worktreePath).env(authorEnv())

// Sync contentrain with base branch (bring main changes into contentrain)
try {
Expand Down Expand Up @@ -413,13 +454,8 @@ export async function mergeBranch(
const worktreePath = join(tmpdir(), `cr-merge-${randomUUID()}`)
await git.raw(['worktree', 'add', worktreePath, CONTENTRAIN_BRANCH])

const wtGit = simpleGit(worktreePath)

// Configure author
const authorName = process.env['CONTENTRAIN_AUTHOR_NAME'] ?? 'Contentrain'
const authorEmail = process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? '[email protected]'
await wtGit.addConfig('user.name', authorName)
await wtGit.addConfig('user.email', authorEmail)
// Commit identity from the environment (see authorEnv) — no config spawns.
const wtGit = simpleGit(worktreePath).env(authorEnv())

try {
// Merge the feature branch into contentrain
Expand Down
Loading