diff --git a/packages/context/src/core/git.service.patch.test.ts b/packages/context/src/core/git.service.patch.test.ts new file mode 100644 index 00000000..de1ccb9f --- /dev/null +++ b/packages/context/src/core/git.service.patch.test.ts @@ -0,0 +1,45 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { GitService } from './git.service.js'; + +async function makeGit() { + const homeDir = await mkdtemp(join(tmpdir(), 'ktx-git-patch-')); + const configDir = join(homeDir, 'config'); + const git = new GitService({ + storage: { configDir, homeDir }, + git: { + userName: 'System User', + userEmail: 'system@example.com', + bootstrapMessage: 'init', + bootstrapAuthor: 'system', + bootstrapAuthorEmail: 'system@example.com', + }, + }); + await git.onModuleInit(); + return { homeDir, configDir, git }; +} + +describe('GitService patch helpers', () => { + it('collects binary-safe no-rename patches and applies them with --3way --index', async () => { + const { homeDir, configDir, git } = await makeGit(); + await mkdir(join(configDir, 'wiki/global'), { recursive: true }); + await writeFile(join(configDir, 'wiki/global/page.md'), 'old\n'); + await git.commitFiles(['wiki/global/page.md'], 'add page', 'System User', 'system@example.com'); + const base = await git.revParseHead(); + + await writeFile(join(configDir, 'wiki/global/page.md'), 'new\n'); + await git.commitFiles(['wiki/global/page.md'], 'edit page', 'System User', 'system@example.com'); + const patchPath = join(homeDir, 'proposal.patch'); + await git.writeBinaryNoRenamePatch(base, 'HEAD', patchPath); + + const targetDir = join(homeDir, 'target'); + await git.addWorktree(targetDir, 'target', base); + const targetGit = git.forWorktree(targetDir); + await targetGit.applyPatchFile3WayIndex(patchPath); + await targetGit.commitStaged('apply proposal', 'System User', 'system@example.com'); + + await expect(readFile(join(targetDir, 'wiki/global/page.md'), 'utf-8')).resolves.toBe('new\n'); + }); +}); diff --git a/packages/context/src/core/git.service.ts b/packages/context/src/core/git.service.ts index 7db4863b..a3e0c133 100644 --- a/packages/context/src/core/git.service.ts +++ b/packages/context/src/core/git.service.ts @@ -1,5 +1,5 @@ import { promises as fs } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import type { SimpleGit } from 'simple-git'; import { noopLogger, resolveConfigDir, type KtxCoreConfig, type KtxLogger } from './config.js'; import { createSimpleGit } from './git-env.js'; @@ -747,6 +747,55 @@ export class GitService { } } + async writeBinaryNoRenamePatch(from: string, to: string, patchPath: string): Promise { + await this.withMutationQueue(async () => { + const patch = await this.git.raw(['diff', '--binary', '--no-renames', `${from}..${to}`]); + await fs.mkdir(dirname(patchPath), { recursive: true }); + await fs.writeFile(patchPath, patch, 'utf-8'); + }); + } + + async applyPatchFile3WayIndex(patchPath: string): Promise { + await this.withMutationQueue(async () => { + await this.git.raw(['apply', '--3way', '--index', patchPath]); + }); + } + + async commitStaged(commitMessage: string, author: string, authorEmail: string): Promise { + return this.withMutationQueue(async () => { + const stagedChanges = await this.git.diff(['--cached', '--name-only']); + if (!stagedChanges.trim()) { + const head = (await this.git.revparse(['HEAD'])).trim(); + const log = await this.git.log({ maxCount: 1 }); + const latest = log.latest; + return { + commitHash: head, + shortHash: head.substring(0, 8), + message: latest?.message ?? '', + author: latest?.author_name ?? '', + authorEmail: latest?.author_email ?? '', + timestamp: latest?.date ?? new Date(0).toISOString(), + committedDate: latest?.date ? new Date(latest.date).toISOString() : new Date(0).toISOString(), + created: false, + }; + } + await this.git.commit(commitMessage, { '--author': `${author} <${authorEmail}>` }); + const head = (await this.git.revparse(['HEAD'])).trim(); + const log = await this.git.log({ maxCount: 1 }); + const latest = log.latest; + return { + commitHash: head, + shortHash: head.substring(0, 8), + message: latest?.message ?? commitMessage, + author: latest?.author_name ?? author, + authorEmail: latest?.author_email ?? authorEmail, + timestamp: latest?.date ?? new Date().toISOString(), + committedDate: latest?.date ? new Date(latest.date).toISOString() : new Date().toISOString(), + created: true, + }; + }); + } + private async fileExists(path: string): Promise { try { await fs.access(path); diff --git a/packages/context/src/ingest/isolated-diff/git-patch.test.ts b/packages/context/src/ingest/isolated-diff/git-patch.test.ts new file mode 100644 index 00000000..fb92016f --- /dev/null +++ b/packages/context/src/ingest/isolated-diff/git-patch.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { assertPatchAllowedForWorkUnit, parsePatchTouchedPaths, textArtifactRoots } from './git-patch.js'; + +describe('isolated diff patch contract', () => { + it('parses touched paths from no-rename git patches', () => { + const patch = [ + 'diff --git a/wiki/global/a.md b/wiki/global/a.md', + 'index 1111111..2222222 100644', + '--- a/wiki/global/a.md', + '+++ b/wiki/global/a.md', + '@@ -1 +1 @@', + '-old', + '+new', + 'diff --git a/semantic-layer/c1/orders.yaml b/semantic-layer/c1/orders.yaml', + 'new file mode 100644', + '--- /dev/null', + '+++ b/semantic-layer/c1/orders.yaml', + '@@ -0,0 +1 @@', + '+name: orders', + '', + ].join('\n'); + + expect(parsePatchTouchedPaths(patch)).toEqual([ + { + path: 'wiki/global/a.md', + oldPath: 'wiki/global/a.md', + newPath: 'wiki/global/a.md', + mode: '100644', + binary: false, + }, + { + path: 'semantic-layer/c1/orders.yaml', + oldPath: 'semantic-layer/c1/orders.yaml', + newPath: 'semantic-layer/c1/orders.yaml', + mode: '100644', + binary: false, + }, + ]); + }); + + it('rejects semantic-layer paths for slDisallowed work units', () => { + const patch = 'diff --git a/semantic-layer/c1/orders.yaml b/semantic-layer/c1/orders.yaml\nindex 1..2 100644\n'; + + expect(() => + assertPatchAllowedForWorkUnit({ + unitKey: 'lookml-mismatch', + patch, + slDisallowed: true, + }), + ).toThrow(/slDisallowed WorkUnit lookml-mismatch touched semantic-layer\/c1\/orders.yaml/); + }); + + it('rejects executable and binary changes under known text artifact roots', () => { + expect(textArtifactRoots).toEqual(['wiki/', 'semantic-layer/']); + + const executablePatch = + 'diff --git a/wiki/global/a.md b/wiki/global/a.md\nold mode 100644\nnew mode 100755\nindex 1..2\n'; + expect(() => + assertPatchAllowedForWorkUnit({ + unitKey: 'wu-1', + patch: executablePatch, + slDisallowed: false, + }), + ).toThrow(/unexpected executable mode under wiki\/global\/a.md/); + + const binaryPatch = [ + 'diff --git a/semantic-layer/c1/orders.yaml b/semantic-layer/c1/orders.yaml', + 'index 1111111..2222222 100644', + 'GIT binary patch', + 'literal 0', + '', + ].join('\n'); + expect(() => + assertPatchAllowedForWorkUnit({ + unitKey: 'wu-2', + patch: binaryPatch, + slDisallowed: false, + }), + ).toThrow(/unexpected binary patch under semantic-layer\/c1\/orders.yaml/); + }); +}); diff --git a/packages/context/src/ingest/isolated-diff/git-patch.ts b/packages/context/src/ingest/isolated-diff/git-patch.ts new file mode 100644 index 00000000..451d448a --- /dev/null +++ b/packages/context/src/ingest/isolated-diff/git-patch.ts @@ -0,0 +1,92 @@ +export const textArtifactRoots = ['wiki/', 'semantic-layer/'] as const; + +export interface PatchTouchedPath { + path: string; + oldPath: string; + newPath: string; + mode: string | null; + binary: boolean; +} + +export interface PatchPolicyInput { + unitKey: string; + patch: string; + slDisallowed: boolean; +} + +function stripPrefix(path: string): string { + return path.replace(/^[ab]\//, ''); +} + +function isTextArtifactPath(path: string): boolean { + return textArtifactRoots.some((root) => path.startsWith(root)); +} + +export function parsePatchTouchedPaths(patch: string): PatchTouchedPath[] { + const lines = patch.split('\n'); + const entries: PatchTouchedPath[] = []; + let current: PatchTouchedPath | null = null; + + const pushCurrent = () => { + if (current) { + entries.push(current); + } + }; + + for (const line of lines) { + const diffMatch = /^diff --git (.+) (.+)$/.exec(line); + if (diffMatch) { + pushCurrent(); + const oldPath = stripPrefix(diffMatch[1] ?? ''); + const newPath = stripPrefix(diffMatch[2] ?? ''); + current = { + path: newPath === '/dev/null' ? oldPath : newPath, + oldPath, + newPath, + mode: null, + binary: false, + }; + continue; + } + if (!current) { + continue; + } + const indexMode = /^index [0-9a-f]+\.\.[0-9a-f]+(?: ([0-7]{6}))?$/.exec(line); + if (indexMode?.[1]) { + current.mode = indexMode[1]; + } + const newMode = /^new mode ([0-7]{6})$/.exec(line); + if (newMode) { + current.mode = newMode[1] ?? current.mode; + } + const newFileMode = /^new file mode ([0-7]{6})$/.exec(line); + if (newFileMode) { + current.mode = newFileMode[1] ?? current.mode; + } + if (line === 'GIT binary patch' || line.startsWith('Binary files ')) { + current.binary = true; + } + } + + pushCurrent(); + return entries; +} + +export function assertPatchAllowedForWorkUnit(input: PatchPolicyInput): PatchTouchedPath[] { + const touched = parsePatchTouchedPaths(input.patch); + for (const entry of touched) { + if (input.slDisallowed && entry.path.startsWith('semantic-layer/')) { + throw new Error(`slDisallowed WorkUnit ${input.unitKey} touched ${entry.path}`); + } + if (!isTextArtifactPath(entry.path)) { + continue; + } + if (entry.binary) { + throw new Error(`unexpected binary patch under ${entry.path}`); + } + if (entry.mode && entry.mode !== '100644') { + throw new Error(`unexpected executable mode under ${entry.path}: ${entry.mode}`); + } + } + return touched; +}