83d61df13b
Pedido: poder llevar la comparativa a una sesión abierta con un modelo para que la use. Eso, y no un informe para imprimir, decide el formato: los ficheros iguales se resumen en una linea en vez de listarse, los binarios solo se mencionan, y las diferencias van en diff unificado, que los modelos leen de forma nativa. Lo que se omite se dice en voz alta — un informe que se deja la mitad en silencio es peor que ninguno, porque se lee como completo. Las cabeceras `--- a/ +++ b/` cuestan dos lineas y convierten cada bloque en un parche de verdad, asi que el modelo puede devolverlo por `git apply` en vez de reescribir el cambio a mano. Que lo sea de verdad esta comprobado ejecutando git sobre la salida en 10 escenarios (sin salto final, insercion al principio, borrado al final...); dos fallos aparecieron asi: interlineaba `-` y `+` en vez de agrupar, y contaba la linea fantasma que deja el ultimo `\n`. Implementado en los dos motores (Swift y TypeScript) con la misma semantica, y verificado que producen el mismo texto **byte a byte** para un mismo caso. Tambien hay salida JSON, para alimentar una herramienta en vez de una charla. UI en las dos apps: copiar al portapapeles primero (que es el gesto real), y guardar/descargar despues. En web, si el portapapeles se niega, se descarga y se avisa: un fallo silencioso ahi acaba en pegar contenido viejo sin saberlo. De paso, un bug que esto destapó: el menu contextual de la web se cerraba en `pointerdown`, quitando los botones antes de que su click llegara — ninguna accion del menu funcionaba (tampoco "Set as base" ni "Expand all"). El smoke test nunca habia pulsado una; ahora si. Closes #9 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
77 lines
3.7 KiB
TypeScript
77 lines
3.7 KiB
TypeScript
/** Hands the export to git itself: the only real proof the patch is valid. */
|
|
import { it, expect } from 'vitest';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { scanFiles, compareTrees } from '../src/engine/tree';
|
|
import { exportMarkdown } from '../src/engine/export';
|
|
|
|
const f = (p: string, b: string) =>
|
|
({ file: new File([new TextEncoder().encode(b)], p.split('/').pop()!), relativePath: p });
|
|
|
|
/** Builds the export for one file pair and returns just the patch body. */
|
|
async function patchFor(before: string, after: string): Promise<string> {
|
|
const left = await scanFiles([f('src/app.txt', before)], 'v1');
|
|
const right = await scanFiles([f('src/app.txt', after)], 'v2');
|
|
const root = await compareTrees(left, right);
|
|
const markdown = await exportMarkdown({ root, leftName: 'v1', rightName: 'v2', mode: null });
|
|
return markdown.split('```diff')[1].split('```')[0].trimStart();
|
|
}
|
|
|
|
/** Applies the patch with real git and returns the resulting file. */
|
|
function applyWithGit(before: string, patch: string): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'kotej-patch-'));
|
|
mkdirSync(dirname(join(dir, 'src/app.txt')), { recursive: true });
|
|
writeFileSync(join(dir, 'src/app.txt'), before);
|
|
writeFileSync(join(dir, 'change.patch'), patch);
|
|
execFileSync('git', ['init', '-q'], { cwd: dir });
|
|
execFileSync('git', ['apply', '--check', 'change.patch'], { cwd: dir });
|
|
execFileSync('git', ['apply', 'change.patch'], { cwd: dir });
|
|
return readFileSync(join(dir, 'src/app.txt'), 'utf8');
|
|
}
|
|
|
|
const CASES: Array<[string, string, string]> = [
|
|
['change in the middle', 'uno\ndos\ntres\n', 'uno\nDOS\ntres\n'],
|
|
['no trailing newline', 'uno\ndos\ntres', 'uno\nDOS\ntres'],
|
|
['insertion at the very top', 'uno\ndos\n', 'cero\nuno\ndos\n'],
|
|
['deletion at the very top', 'cero\nuno\ndos\n', 'uno\ndos\n'],
|
|
['append at the end', 'uno\ndos\n', 'uno\ndos\ntres\n'],
|
|
['deletion at the end', 'uno\ndos\ntres\n', 'uno\ndos\n'],
|
|
['blank line kept at the end', 'uno\n\n', 'DOS\n\n'],
|
|
['two changes far apart',
|
|
Array.from({ length: 40 }, (_, i) => `line ${i}`).join('\n') + '\n',
|
|
Array.from({ length: 40 }, (_, i) => (i === 2 || i === 35 ? `LINE ${i}` : `line ${i}`)).join('\n') + '\n'],
|
|
['everything replaced', 'a\nb\nc\n', 'x\ny\nz\n'],
|
|
];
|
|
|
|
for (const [label, before, after] of CASES) {
|
|
it(`git apply handles: ${label}`, async () => {
|
|
expect(applyWithGit(before, await patchFor(before, after))).toBe(after);
|
|
});
|
|
}
|
|
|
|
it('git apply accepts the exported diff and reproduces side B', async () => {
|
|
const beforeBody = 'uno\ndos\ntres\ncuatro\ncinco\nseis\nsiete\nocho\n';
|
|
const afterBody = 'uno\nDOS cambiado\ntres\ncuatro\ncinco\nseis\nSIETE\nocho\n';
|
|
|
|
const left = await scanFiles([f('src/app.txt', beforeBody)], 'v1');
|
|
const right = await scanFiles([f('src/app.txt', afterBody)], 'v2');
|
|
const root = await compareTrees(left, right);
|
|
const markdown = await exportMarkdown({ root, leftName: 'v1', rightName: 'v2', mode: null });
|
|
|
|
const patch = markdown.split('```diff')[1].split('```')[0].trimStart();
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'kotej-patch-'));
|
|
mkdirSync(dirname(join(dir, 'src/app.txt')), { recursive: true });
|
|
writeFileSync(join(dir, 'src/app.txt'), beforeBody);
|
|
writeFileSync(join(dir, 'change.patch'), patch);
|
|
execFileSync('git', ['init', '-q'], { cwd: dir });
|
|
|
|
// --check first: git refuses a malformed or non-applying patch outright.
|
|
execFileSync('git', ['apply', '--check', 'change.patch'], { cwd: dir });
|
|
execFileSync('git', ['apply', 'change.patch'], { cwd: dir });
|
|
|
|
expect(readFileSync(join(dir, 'src/app.txt'), 'utf8')).toBe(afterBody);
|
|
});
|