diff --git a/package.json b/package.json index bea1caf..e5861ab 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ }, "scripts": { "start": "node cli.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node tests/run-tests.js" }, "keywords": [], "author": "", diff --git a/tests/run-tests.js b/tests/run-tests.js new file mode 100644 index 0000000..8a6105c --- /dev/null +++ b/tests/run-tests.js @@ -0,0 +1,626 @@ +#!/usr/bin/env node +import { promises as fs } from 'node:fs'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { run as runPatch } from '../tools/patch_files.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); +const sandboxRoot = path.resolve(repoRoot, 'tmp', 'patch-tests'); + +async function rimraf(dir) { + await fs.rm(dir, { recursive: true, force: true }); +} + +async function ensureDir(dir) { + await fs.mkdir(dir, { recursive: true }); +} + +async function writeFiles(baseDir, filesMap) { + for (const [rel, content] of Object.entries(filesMap || {})) { + const filePath = path.resolve(baseDir, rel); + await ensureDir(path.dirname(filePath)); + await fs.writeFile(filePath, content, 'utf8'); + } +} + +async function readFileIfExists(filePath) { + try { + return await fs.readFile(filePath, 'utf8'); + } catch { + return undefined; + } +} + +function buildPatch(lines, useCRLF = false) { + const eol = useCRLF ? '\r\n' : '\n'; + return lines.join(eol); +} + +function begin() { return '*** Begin Patch'; } +function end() { return '*** End Patch'; } +function addFile(p) { return `*** Add File: ${p}`; } +function updateFile(p) { return `*** Update File: ${p}`; } +function moveTo(p) { return `*** Move to: ${p}`; } +function endOfFile() { return '*** End of File'; } + +function k(line) { return ` ${line}`; } +function d(line) { return `-${line}`; } +function a(line) { return `+${line}`; } +function at(atLine = '') { return atLine ? `@@ ${atLine}` : '@@'; } + +function slugify(name) { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); +} + +function expectEqual(actual, expected, label) { + if (actual !== expected) { + const ellipsize = (s) => (s.length > 400 ? s.slice(0, 400) + '…' : s); + throw new Error(`${label} mismatch.\nExpected:\n${ellipsize(expected ?? '<>')}\nActual:\n${ellipsize(actual ?? '<>')}`); + } +} + +async function runCase(index, testCase) { + const idx = String(index + 1).padStart(2, '0'); + const caseDir = path.resolve(sandboxRoot, `${idx}-${slugify(testCase.name)}`); + await rimraf(caseDir); + await ensureDir(caseDir); + + // Setup initial files + await writeFiles(caseDir, typeof testCase.before === 'function' ? await testCase.before({ dir: caseDir }) : (testCase.before || {})); + + const patchText = await testCase.patch({ dir: caseDir }); + + let threw = false; + let errorMessage = ''; + try { + const result = await runPatch({ patch: patchText }); + if (testCase.expect?.resultEquals) { + expectEqual(result, testCase.expect.resultEquals, 'Tool result'); + } + } catch (err) { + threw = true; + errorMessage = err?.message || String(err); + } + + if (testCase.expect?.error) { + if (!threw) { + throw new Error(`Expected error matching ${testCase.expect.error} but call succeeded`); + } + const re = typeof testCase.expect.error === 'string' ? new RegExp(testCase.expect.error.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : testCase.expect.error; + if (!re.test(errorMessage)) { + throw new Error(`Error did not match.\nExpected: ${re}\nActual: ${errorMessage}`); + } + } else if (threw) { + throw new Error(`Unexpected error: ${errorMessage}`); + } + + if (testCase.expect?.files) { + for (const [rel, expectedContent] of Object.entries(testCase.expect.files)) { + const filePath = path.resolve(caseDir, rel); + const content = await readFileIfExists(filePath); + if (content === undefined) { + throw new Error(`Expected file missing: ${rel}`); + } + expectEqual(content, expectedContent, `Content of ${rel}`); + } + } + + if (testCase.expect?.exists) { + for (const rel of testCase.expect.exists) { + const filePath = path.resolve(caseDir, rel); + if (!fsSync.existsSync(filePath)) { + throw new Error(`Expected path to exist: ${rel}`); + } + } + } + + if (testCase.expect?.notExists) { + for (const rel of testCase.expect.notExists) { + const filePath = path.resolve(caseDir, rel); + if (fsSync.existsSync(filePath)) { + throw new Error(`Expected path NOT to exist: ${rel}`); + } + } + } +} + +function cases() { + const list = []; + + // 1. Add simple file + list.push({ + name: 'add simple file', + before: {}, + patch: async ({ dir }) => buildPatch([ + begin(), + addFile(path.resolve(dir, 'file1.txt')), + a('hello'), + a('world'), + end(), + ]), + expect: { + files: { 'file1.txt': 'hello\nworld' } + } + }); + + // 2. Add nested directories + list.push({ + name: 'add nested directories', + before: {}, + patch: async ({ dir }) => buildPatch([ + begin(), + addFile(path.resolve(dir, 'a/b/c.txt')), + a('alpha'), + a('beta'), + end(), + ]), + expect: { + files: { 'a/b/c.txt': 'alpha\nbeta' } + } + }); + + // 3. Update simple replacement + list.push({ + name: 'update simple replacement', + before: { 'greet.txt': 'line1\nreplace me\nline3' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'greet.txt')), + k('line1'), + d('replace me'), + a('replaced'), + k('line3'), + end(), + ]), + expect: { + files: { 'greet.txt': 'line1\nreplaced\nline3' } + } + }); + + // 4. Update multiple chunks + list.push({ + name: 'update multiple chunks', + before: { 'multi.txt': 'l1\nl2\nl3\nl4\nl5\nl6\nl7' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'multi.txt')), + k('l1'), + k('l2'), + d('l3'), + a('L3'), + k('l4'), + k('l5'), + d('l6'), + a('L6'), + k('l7'), + end(), + ]), + expect: { + files: { 'multi.txt': 'l1\nl2\nL3\nl4\nl5\nL6\nl7' } + } + }); + + // 5. Insert at beginning + list.push({ + name: 'insert at beginning', + before: { 'begin.txt': 'B1\nB2' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'begin.txt')), + a('A0'), + k('B1'), + k('B2'), + end(), + ]), + expect: { files: { 'begin.txt': 'A0\nB1\nB2' } } + }); + + // 6. Insert at end with EOF marker + list.push({ + name: 'insert at end with EOF', + before: { 'end.txt': 'E1\nE2' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'end.txt')), + k('E1'), + k('E2'), + a('E3'), + endOfFile(), + end(), + ]), + expect: { files: { 'end.txt': 'E1\nE2\nE3' } } + }); + + // 7. Delete a line + list.push({ + name: 'delete a line', + before: { 'delete.txt': 'X\nY\nZ' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'delete.txt')), + k('X'), + d('Y'), + k('Z'), + end(), + ]), + expect: { files: { 'delete.txt': 'X\nZ' } } + }); + + // 8. Whitespace rstrip match + list.push({ + name: 'rstrip whitespace match', + before: { 'ws.txt': 'foo \nbar' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'ws.txt')), + k('foo'), // matches 'foo ' via rstrip + k('bar'), + end(), + ]), + expect: { files: { 'ws.txt': 'foo \nbar' } } + }); + + // 9. Trim match + list.push({ + name: 'trim whitespace match', + before: { 'trim.txt': ' alpha \n beta' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'trim.txt')), + k('alpha'), // matches ' alpha ' via trim + k(' beta'), + end(), + ]), + expect: { files: { 'trim.txt': ' alpha \n beta' } } + }); + + // 10. Use def_str to anchor (do not duplicate the anchor line in context) + list.push({ + name: 'def_str anchor', + before: { 'code.js': 'function a() {}\nfunction greet() {\n return 1;\n}\nfunction z() {}' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'code.js')), + at('function greet() {'), + // context starts at the next line after the def_str match + d(' return 1;'), + a(' return 2;'), + k('}'), + end(), + ]), + expect: { files: { 'code.js': 'function a() {}\nfunction greet() {\n return 2;\n}\nfunction z() {}' } } + }); + + // 11. Bare @@ marker allowed + list.push({ + name: 'bare @@ marker', + before: { 'marker.txt': 'L1\nL2\nL3' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'marker.txt')), + at(), + k('L1'), + d('L2'), + a('X2'), + k('L3'), + end(), + ]), + expect: { files: { 'marker.txt': 'L1\nX2\nL3' } } + }); + + // 12. Move/rename file with content change + list.push({ + name: 'move and change', + before: { 'mv.txt': 'A\nB' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'mv.txt')), + moveTo(path.resolve(dir, 'moved/mv.txt')), + k('A'), + d('B'), + a('C'), + end(), + ]), + expect: { files: { 'moved/mv.txt': 'A\nC' }, notExists: ['mv.txt'] } + }); + + // 13. Delete file + list.push({ + name: 'delete file', + before: { 'del/me.txt': 'bye' }, + patch: async ({ dir }) => buildPatch([ + begin(), + `*** Delete File: ${path.resolve(dir, 'del/me.txt')}`, + end(), + ]), + expect: { notExists: ['del/me.txt'] } + }); + + // 14. Combined add/update/delete + list.push({ + name: 'combined operations', + before: { 'combo/u.txt': 'X\nY', 'combo/d.txt': 'gone' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'combo/u.txt')), + k('X'), + a('Z'), + k('Y'), + `*** Delete File: ${path.resolve(dir, 'combo/d.txt')}`, + addFile(path.resolve(dir, 'combo/a.txt')), + a('new'), + end(), + ]), + expect: { files: { 'combo/u.txt': 'X\nZ\nY', 'combo/a.txt': 'new' }, notExists: ['combo/d.txt'] } + }); + + // 15. Add with CRLF patch + list.push({ + name: 'add with CRLF patch', + before: {}, + patch: async ({ dir }) => buildPatch([ + begin(), + addFile(path.resolve(dir, 'crlf/add.txt')), + a('one'), + a('two'), + end(), + ], true), + expect: { files: { 'crlf/add.txt': 'one\ntwo' } } + }); + + // 16. Update with CRLF patch + list.push({ + name: 'update with CRLF patch', + before: { 'crlf/up.txt': 'A\nB' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'crlf/up.txt')), + k('A'), + d('B'), + a('C'), + end(), + ], true), + expect: { files: { 'crlf/up.txt': 'A\nC' } } + }); + + // 17. Ambiguous content resolved by def_str + list.push({ + name: 'ambiguous resolved by def_str', + before: { 'amb.js': 'function target() {\n let x = 1;\n}\nfunction target() {\n let x = 2;\n}' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'amb.js')), + at('function target() {'), + k('function target() {'), + d(' let x = 2;'), + a(' let x = 42;'), + k('}'), + end(), + ]), + expect: { files: { 'amb.js': 'function target() {\n let x = 1;\n}\nfunction target() {\n let x = 42;\n}' } } + }); + + // 18. Update missing file -> error + list.push({ + name: 'update missing file error', + before: {}, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'missing.txt')), + k('x'), + end(), + ]), + expect: { error: /Patch error: Update File Error - missing file:/ } + }); + + // 19. Delete missing file -> error + list.push({ + name: 'delete missing file error', + before: {}, + patch: async ({ dir }) => buildPatch([ + begin(), + `*** Delete File: ${path.resolve(dir, 'nope.txt')}`, + end(), + ]), + expect: { error: /Patch error: Delete File Error - missing file:/ } + }); + + // 20. Add existing file -> error + list.push({ + name: 'add existing file error', + before: { 'exists.txt': 'already' }, + patch: async ({ dir }) => buildPatch([ + begin(), + addFile(path.resolve(dir, 'exists.txt')), + a('new'), + end(), + ]), + expect: { error: /Patch error: Add File Error - file already exists:/ } + }); + + // 21. Duplicate update -> error + list.push({ + name: 'duplicate update error', + before: { 'dup.txt': 'X' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'dup.txt')), + k('X'), + updateFile(path.resolve(dir, 'dup.txt')), + k('X'), + end(), + ]), + expect: { error: /Patch error: Duplicate update for file:/ } + }); + + // 22. Invalid line in update section -> error + list.push({ + name: 'invalid line in section error', + before: { 'bad.txt': 'A\nB' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'bad.txt')), + k('A'), + '?weird', + k('B'), + end(), + ]), + expect: { error: /Patch error: Invalid Line:/ } + }); + + // 23. Missing end sentinel -> error + list.push({ + name: 'missing end sentinel error', + before: {}, + patch: async ({ dir }) => buildPatch([ + begin(), + addFile(path.resolve(dir, 'x.txt')), + a('x'), + // Intentionally no end sentinel here; we'll strip it below + ]).replace(/\*\*\* End Patch$/, ''), + expect: { error: /Patch error: Invalid patch text - missing sentinels/ } + }); + + // 24. Unknown line while parsing -> error + list.push({ + name: 'unknown line while parsing error', + before: {}, + patch: async () => buildPatch([ + begin(), + 'some random line', + end(), + ]), + expect: { error: /Patch error: Unknown line while parsing:/ } + }); + + // 25. Add empty file (no + lines) + list.push({ + name: 'add empty file', + before: {}, + patch: async ({ dir }) => buildPatch([ + begin(), + addFile(path.resolve(dir, 'empty.txt')), + end(), + ]), + expect: { files: { 'empty.txt': '' } } + }); + + // 26. Replace whole file contents + list.push({ + name: 'replace whole file', + before: { 'whole.txt': 'a\nb\nc' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'whole.txt')), + d('a'), + d('b'), + d('c'), + a('x'), + a('y'), + end(), + ]), + expect: { files: { 'whole.txt': 'x\ny' } } + }); + + // 27. Multiple file updates + list.push({ + name: 'multi-file updates', + before: { 'm1.txt': '1', 'm2.txt': 'A' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'm1.txt')), + d('1'), + a('2'), + updateFile(path.resolve(dir, 'm2.txt')), + d('A'), + a('B'), + end(), + ]), + expect: { files: { 'm1.txt': '2', 'm2.txt': 'B' } } + }); + + // 28. Rename only (no content changes) + list.push({ + name: 'rename only', + before: { 'r/from.txt': 'same' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'r/from.txt')), + moveTo(path.resolve(dir, 'r/to.txt')), + end(), + ]), + expect: { files: { 'r/to.txt': 'same' }, notExists: ['r/from.txt'] } + }); + + // 29. EOF context matching at end + list.push({ + name: 'EOF context matching', + before: { 'eof.txt': 'first\nsecond\nthird' }, + patch: async ({ dir }) => buildPatch([ + begin(), + updateFile(path.resolve(dir, 'eof.txt')), + k('second'), + a('inserted'), + k('third'), + endOfFile(), + end(), + ]), + expect: { files: { 'eof.txt': 'first\nsecond\ninserted\nthird' } } + }); + + // 30. Add multiple files in single patch + list.push({ + name: 'add multiple files', + before: {}, + patch: async ({ dir }) => buildPatch([ + begin(), + addFile(path.resolve(dir, 'multi/a.txt')), + a('A'), + addFile(path.resolve(dir, 'multi/b.txt')), + a('B'), + addFile(path.resolve(dir, 'multi/c.txt')), + a('C'), + end(), + ]), + expect: { files: { 'multi/a.txt': 'A', 'multi/b.txt': 'B', 'multi/c.txt': 'C' } } + }); + + return list; +} + +async function main() { + const all = cases(); + await ensureDir(sandboxRoot); + const results = []; + let passed = 0; + let failed = 0; + + for (let i = 0; i < all.length; i++) { + const tc = all[i]; + const label = `${String(i + 1).padStart(2, '0')} ${tc.name}`; + try { + await runCase(i, tc); + console.log(`✓ ${label}`); + results.push({ name: tc.name, ok: true }); + passed++; + } catch (err) { + console.error(`✗ ${label}`); + console.error(String(err?.stack || err)); + results.push({ name: tc.name, ok: false, error: String(err?.message || err) }); + failed++; + } + } + + console.log(''); + console.log(`Passed: ${passed}, Failed: ${failed}, Total: ${all.length}`); + if (failed > 0) process.exit(1); +} + +main().catch((err) => { + console.error('Fatal error in test runner:', err); + process.exit(1); +}); + + diff --git a/tools/patch_files.js b/tools/patch_files.js index 076ffc3..cfa0864 100644 --- a/tools/patch_files.js +++ b/tools/patch_files.js @@ -1,19 +1,14 @@ #!/usr/bin/env node -/** - * A self-contained JavaScript utility for applying human-readable - * "pseudo-diff" patch files to a collection of text files. - * - - - - +const desc = ` +This is a custom utility that makes it more convenient to add, remove, move, or edit code files. 'apply_patch' effectively allows you to execute a diff/patch against a file, +but the format of the diff specification is unique to this task, so pay careful attention to these instructions. +To use the 'apply_patch' command, you should pass a message of the following structure as "input": *** Begin Patch [YOUR_PATH] *** End Patch - Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format. *** [ACTION] File: [path/to/file] -> ACTION can be one of Add, Update, or Delete. @@ -24,7 +19,7 @@ For each snippet of code that needs to be changed, repeat the following: [context_after] -> See below for further instructions on context. For instructions on [context_before] and [context_after]: -- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change’s [context_after] lines in the second change’s [context_before] lines. +- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first changes [context_after] lines in the second changes [context_before] lines. - If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have: @@ class BaseClass [3 lines of pre-context] @@ -32,7 +27,7 @@ For instructions on [context_before] and [context_after]: + [new_code] [3 lines of post-context] -- If a code block is repeated so many times in a class or function such that even a single @@ statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance: +- If a code block is repeated so many times in a class or function such that even a single @@ statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple '@@' statements to jump to the right context. For instance: @@ class BaseClass @@ def method(): @@ -42,12 +37,7 @@ For instructions on [context_before] and [context_after]: [3 lines of post-context] Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as "input" to this function, in order to apply a patch, is shown below. - - * - * - * - */ - +`; // --------------------------------------------------------------------------- // // Domain objects // --------------------------------------------------------------------------- // @@ -109,16 +99,55 @@ class Patch { } } +// --------------------------------------------------------------------------- // +// Path utilities for chroot functionality +// --------------------------------------------------------------------------- // +function normalizePath(path) { + return path.replace(/\\/g, '/').replace(/\/+/g, '/'); +} + +function joinPaths(...paths) { + return normalizePath(paths.filter(p => p).join('/')); +} + +function resolvePath(chroot, filepath) { + if (!chroot) return filepath; + + // Remove leading slash from filepath if present + const cleanFilepath = filepath.startsWith('/') ? filepath.substring(1) : filepath; + + // Join chroot and filepath + const resolved = joinPaths(chroot, cleanFilepath); + + // Ensure it starts with / + return resolved.startsWith('/') ? resolved : '/' + resolved; +} + +function unresolvePath(chroot, filepath) { + if (!chroot) return filepath; + + const chrootPath = chroot.startsWith('/') ? chroot : '/' + chroot; + const cleanFilepath = filepath.startsWith('/') ? filepath : '/' + filepath; + + if (cleanFilepath.startsWith(chrootPath)) { + const relativePath = cleanFilepath.substring(chrootPath.length); + return relativePath.startsWith('/') ? relativePath : '/' + relativePath; + } + + return filepath; +} + // --------------------------------------------------------------------------- // // Patch text parser // --------------------------------------------------------------------------- // class Parser { - constructor(current_files, lines) { + constructor(current_files, lines, chroot = null) { this.current_files = current_files; this.lines = lines; this.index = 0; this.patch = new Patch(); this.fuzz = 0; + this.chroot = chroot; } // ------------- low-level helpers -------------------------------------- // @@ -186,43 +215,52 @@ class Parser { // ---------- UPDATE ---------- // let path = this.read_str("*** Update File: "); if (path) { - if (path in this.patch.actions) { - throw new DiffError(`Duplicate update for file: ${path}`); + // Resolve path with chroot + const resolvedPath = resolvePath(this.chroot, path); + + if (resolvedPath in this.patch.actions) { + throw new DiffError(`Duplicate update for file: ${resolvedPath}`); } const move_to = this.read_str("*** Move to: "); - if (!(path in this.current_files)) { - throw new DiffError(`Update File Error - missing file: ${path}`); + if (!(resolvedPath in this.current_files)) { + throw new DiffError(`Update File Error - missing file: ${resolvedPath}`); } - const text = this.current_files[path]; + const text = this.current_files[resolvedPath]; const action = this._parse_update_file(text); - action.move_path = move_to || null; - this.patch.actions[path] = action; + action.move_path = move_to ? resolvePath(this.chroot, move_to) : null; + this.patch.actions[resolvedPath] = action; continue; } // ---------- DELETE ---------- // path = this.read_str("*** Delete File: "); if (path) { - if (path in this.patch.actions) { - throw new DiffError(`Duplicate delete for file: ${path}`); + // Resolve path with chroot + const resolvedPath = resolvePath(this.chroot, path); + + if (resolvedPath in this.patch.actions) { + throw new DiffError(`Duplicate delete for file: ${resolvedPath}`); } - if (!(path in this.current_files)) { - throw new DiffError(`Delete File Error - missing file: ${path}`); + if (!(resolvedPath in this.current_files)) { + throw new DiffError(`Delete File Error - missing file: ${resolvedPath}`); } - this.patch.actions[path] = new PatchAction(ActionType.DELETE); + this.patch.actions[resolvedPath] = new PatchAction(ActionType.DELETE); continue; } // ---------- ADD ---------- // path = this.read_str("*** Add File: "); if (path) { - if (path in this.patch.actions) { - throw new DiffError(`Duplicate add for file: ${path}`); + // Resolve path with chroot + const resolvedPath = resolvePath(this.chroot, path); + + if (resolvedPath in this.patch.actions) { + throw new DiffError(`Duplicate add for file: ${resolvedPath}`); } - if (path in this.current_files) { - throw new DiffError(`Add File Error - file already exists: ${path}`); + if (resolvedPath in this.current_files) { + throw new DiffError(`Add File Error - file already exists: ${resolvedPath}`); } - this.patch.actions[path] = this._parse_add_file(); + this.patch.actions[resolvedPath] = this._parse_add_file(); continue; } @@ -512,7 +550,7 @@ function _get_updated_file(text, action, path) { return dest_lines.join("\n"); } -function patch_to_commit(patch, orig) { +function patch_to_commit(patch, orig, chroot = null) { const commit = new Commit(); for (const [path, action] of Object.entries(patch.actions)) { if (action.type === ActionType.DELETE) { @@ -534,11 +572,12 @@ function patch_to_commit(patch, orig) { ); } else if (action.type === ActionType.UPDATE) { const new_content = _get_updated_file(orig[path], action, path); + const move_path = action.move_path ? unresolvePath(chroot, action.move_path) : null; commit.changes[path] = new FileChange( ActionType.UPDATE, orig[path], new_content, - action.move_path + move_path ); } } @@ -548,7 +587,7 @@ function patch_to_commit(patch, orig) { // --------------------------------------------------------------------------- // // User-facing helpers // --------------------------------------------------------------------------- // -function text_to_patch(text, orig) { +function text_to_patch(text, orig, chroot = null) { // Handle different line ending scenarios let lines = text.split(/\r?\n/); @@ -574,13 +613,13 @@ function text_to_patch(text, orig) { throw new DiffError("Invalid patch text - missing sentinels"); } - const parser = new Parser(orig, lines); + const parser = new Parser(orig, lines, chroot); parser.index = 1; parser.parse(); return [parser.patch, parser.fuzz]; } -function identify_files_needed(text) { +function identify_files_needed(text, chroot = null) { // Handle line splitting consistently let lines = text.split(/\r?\n/); if (lines.length > 0 && lines[lines.length - 1] === "") { @@ -589,16 +628,16 @@ function identify_files_needed(text) { const updateFiles = lines .filter(line => line.startsWith("*** Update File: ")) - .map(line => line.substring("*** Update File: ".length)); + .map(line => resolvePath(chroot, line.substring("*** Update File: ".length))); const deleteFiles = lines .filter(line => line.startsWith("*** Delete File: ")) - .map(line => line.substring("*** Delete File: ".length)); + .map(line => resolvePath(chroot, line.substring("*** Delete File: ".length))); return [...updateFiles, ...deleteFiles]; } -function identify_files_added(text) { +function identify_files_added(text, chroot = null) { // Handle line splitting consistently let lines = text.split(/\r?\n/); if (lines.length > 0 && lines[lines.length - 1] === "") { @@ -607,7 +646,7 @@ function identify_files_added(text) { return lines .filter(line => line.startsWith("*** Add File: ")) - .map(line => line.substring("*** Add File: ".length)); + .map(line => resolvePath(chroot, line.substring("*** Add File: ".length))); } // --------------------------------------------------------------------------- // @@ -621,7 +660,7 @@ function load_files(paths, open_fn) { return result; } -function apply_commit(commit, write_fn, remove_fn) { +function apply_commit(commit, write_fn, remove_fn, chroot = null) { for (const [path, change] of Object.entries(commit.changes)) { if (change.type === ActionType.DELETE) { remove_fn(path); @@ -634,7 +673,7 @@ function apply_commit(commit, write_fn, remove_fn) { if (change.new_content === null) { throw new DiffError(`UPDATE change for ${path} has no new content`); } - const target = change.move_path || path; + const target = change.move_path ? resolvePath(chroot, change.move_path) : path; write_fn(target, change.new_content); if (change.move_path) { remove_fn(path); @@ -643,15 +682,15 @@ function apply_commit(commit, write_fn, remove_fn) { } } -function process_patch(text, open_fn, write_fn, remove_fn) { +function process_patch(text, open_fn, write_fn, remove_fn, chroot = null) { if (!text.startsWith("*** Begin Patch")) { throw new DiffError("Patch text must start with *** Begin Patch"); } - const paths = identify_files_needed(text); + const paths = identify_files_needed(text, chroot); const orig = load_files(paths, open_fn); - const [patch, _fuzz] = text_to_patch(text, orig); - const commit = patch_to_commit(patch, orig); - apply_commit(commit, write_fn, remove_fn); + const [patch, _fuzz] = text_to_patch(text, orig, chroot); + const commit = patch_to_commit(patch, orig, chroot); + apply_commit(commit, write_fn, remove_fn, chroot); return "Done!"; } @@ -683,13 +722,13 @@ function remove_file(filepath) { export default { type: "function", name: "patch_files", - description: "Apply a unified diff patch to files within a chroot directory, with option to reverse the patch", + description: "Apply a unified diff patch " + desc, parameters: { type: "object", properties: { patch: { type: "string", - description: "The unidiff patch string to apply.", + description: "The patch string to apply.", } }, required: ["patch"], @@ -706,7 +745,8 @@ export async function run(args) { args.patch, open_file, write_file, - remove_file + remove_file, + '/home/seb/src/aiTools/tmp' ); return result; } catch (error) { @@ -715,4 +755,4 @@ export async function run(args) { } throw error; } -} \ No newline at end of file +}