diff --git a/lib/itemize.js b/lib/itemize.js new file mode 100644 index 0000000..738fee4 --- /dev/null +++ b/lib/itemize.js @@ -0,0 +1,56 @@ +/** + * Parse rsync --itemize-changes output into a structured change list. + * + * rsync itemize format: 11-character code + space + path + * + * Code structure: YXcstpoguax + * Y = update type: > (transfer), * (message/delete), c (local change), . (no update), h (hard link) + * X = file type: f (file), d (dir), L (symlink), D (device), S (special) + * remaining chars = what changed (size, time, perms, etc.) or '+++++++++' for new + * + * We care about: + * >f... = file transferred (new or modified) + * *deleting = file deleted + * cd... = directory (ignored for delta purposes) + */ + +/** + * @typedef {{ status: 'added'|'modified'|'deleted', path: string }} Change + */ + +/** + * Parse rsync --itemize-changes stdout into a list of file changes. + * @param {string} output + * @returns {Change[]} + */ +export function parseItemize(output) { + const changes = []; + + for (const raw of output.split('\n')) { + const line = raw.trimEnd(); + if (!line) continue; + + // Deleted files: "*deleting path/to/file" + if (line.startsWith('*deleting ')) { + const path = line.slice('*deleting '.length).trimStart(); + // Skip directory deletions (trailing slash) + if (!path.endsWith('/')) { + changes.push({ status: 'deleted', path }); + } + continue; + } + + // File transfers: ">f......... path" (new or modified) + if (line.length > 12 && line[0] === '>' && line[1] === 'f') { + const code = line.slice(0, 11); + const path = line.slice(12); + const isNew = code.slice(2) === '+++++++++'; + changes.push({ status: isNew ? 'added' : 'modified', path }); + continue; + } + + // Everything else (dirs, symlinks, attribute-only changes) — ignore + } + + return changes; +} diff --git a/lib/spawn.js b/lib/spawn.js index 1174a14..7fa812b 100644 --- a/lib/spawn.js +++ b/lib/spawn.js @@ -4,17 +4,14 @@ import { spawn } from 'child_process'; /** - * Spawn a process and stream its output. + * Spawn a process and stream its output to stdout/stderr. * @param {string} cmd * @param {string[]} args - * @param {{ dryRun?: boolean, label?: string }} opts + * @param {{ dryRun?: boolean }} opts * @returns {Promise} */ -export async function run(cmd, args, { dryRun = false, label } = {}) { - const display = [cmd, ...args].join(' '); - if (label) console.log(`[${label}] ${display}`); - else console.log(`$ ${display}`); - +export async function run(cmd, args, { dryRun = false } = {}) { + console.log(`$ ${[cmd, ...args].join(' ')}`); if (dryRun) return; return new Promise((resolve, reject) => { @@ -26,3 +23,25 @@ export async function run(cmd, args, { dryRun = false, label } = {}) { }); }); } + +/** + * Spawn a process and capture stdout as a string. + * stderr is inherited (shown to user). Never used in dry-run context. + * @param {string} cmd + * @param {string[]} args + * @returns {Promise} + */ +export async function capture(cmd, args) { + console.log(`$ ${[cmd, ...args].join(' ')}`); + + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'] }); + const chunks = []; + child.stdout.on('data', chunk => chunks.push(chunk)); + child.on('error', reject); + child.on('close', code => { + if (code === 0) resolve(Buffer.concat(chunks).toString('utf8')); + else reject(new Error(`${cmd} exited with code ${code}`)); + }); + }); +}