Add capture() to spawn, add rsync itemize parser

This commit is contained in:
2026-03-07 01:07:51 +00:00
parent 84801a7971
commit 30b90193d7
2 changed files with 82 additions and 7 deletions

56
lib/itemize.js Normal file
View File

@@ -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;
}

View File

@@ -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<void>}
*/
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<string>}
*/
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}`));
});
});
}