mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-09-20 04:23:02 +08:00
6682ea6e3e
Four fixes from the megafix review cycle: - mirror-dir.cjs: refuse identical or ancestor/descendant source and destination roots before any filesystem mutation. The --delete pass enumerated the destination first, so a source nested inside the destination was seen as stale and recursively deleted (Greptile P1). - worker-logs.cjs: track file identity (dev/ino) in the --follow loop. A rename-and-recreate rotation whose replacement matched the old offset's byte size was skipped forever by the size-only check (Greptile P2). - windows-git-bash-preflight: derive bin\bash.exe from every `git` PATH hit instead of only the first. Git for Windows puts both mingw64\bin\git.exe and cmd\git.exe on PATH; when mingw64 sorts first the derivation missed the real bash and doctor reported a false negative (tester report, D:\JavaTool\GIT layout). - doctor: a missing .install-version marker with node_modules present is now a warn, not a required failure. The marker is written only by the npx installer — marketplace-flow and dev build-and-sync installs never have one, so the check always failed for them and its repair suggestion would overwrite the checkout under test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JT1VTKoaTf7VfePb7nVfwz
248 lines
8.5 KiB
JavaScript
248 lines
8.5 KiB
JavaScript
const {
|
|
chmodSync,
|
|
copyFileSync,
|
|
lstatSync,
|
|
lutimesSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readlinkSync,
|
|
realpathSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
utimesSync,
|
|
} = require('fs');
|
|
const path = require('path');
|
|
|
|
// Stand-in for `rsync -a --delete --exclude=...`, which is unavailable on
|
|
// Windows. Supported pattern syntax is the rsync subset this repo actually
|
|
// uses: `*` (no `/`), `**` (any), `?`, a leading `/` to anchor at the mirror
|
|
// root, and a trailing `/` to match directories only. Unanchored patterns match
|
|
// at any depth, on directory boundaries, exactly as rsync matches them.
|
|
//
|
|
// `-a` is `-rlptgoD`. Reconciled here: recursion (-r), symlinks as symlinks
|
|
// (-l), permissions (-p, including setuid/setgid/sticky), and modification
|
|
// times (-t) on files, directories and symlinks alike. Not reconciled: owner
|
|
// and group (-o/-g, which rsync itself can only apply as root) and device or
|
|
// special files (-D, likewise root-only and absent from a source checkout).
|
|
// `-a` does not imply -H/-A/-X, so hardlinks, ACLs and xattrs are out of scope
|
|
// for both tools.
|
|
const PERMISSION_MASK = 0o7777;
|
|
|
|
function globToRegExpSource(pattern) {
|
|
let source = '';
|
|
for (let index = 0; index < pattern.length; index++) {
|
|
const char = pattern[index];
|
|
if (char === '*') {
|
|
if (pattern[index + 1] === '*') {
|
|
source += '.*';
|
|
index++;
|
|
} else {
|
|
source += '[^/]*';
|
|
}
|
|
continue;
|
|
}
|
|
if (char === '?') {
|
|
source += '[^/]';
|
|
continue;
|
|
}
|
|
source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
return source;
|
|
}
|
|
|
|
function compileExcludes(patterns) {
|
|
return patterns
|
|
.filter(Boolean)
|
|
.map(pattern => {
|
|
let body = pattern;
|
|
const dirOnly = body.endsWith('/');
|
|
if (dirOnly) body = body.slice(0, -1);
|
|
const anchored = body.startsWith('/');
|
|
if (anchored) body = body.slice(1);
|
|
const source = globToRegExpSource(body);
|
|
return { dirOnly, matcher: new RegExp(anchored ? `^${source}$` : `(^|/)${source}$`) };
|
|
});
|
|
}
|
|
|
|
function isExcluded(rules, relativePath, isDirectory) {
|
|
return rules.some(rule => (!rule.dirOnly || isDirectory) && rule.matcher.test(relativePath));
|
|
}
|
|
|
|
function joinRelative(base, name) {
|
|
return base ? `${base}/${name}` : name;
|
|
}
|
|
|
|
// rsync compares whole seconds by default.
|
|
function sameModifiedTime(destStat, sourceStat) {
|
|
return Math.floor(destStat.mtimeMs / 1000) === Math.floor(sourceStat.mtimeMs / 1000);
|
|
}
|
|
|
|
function samePermissions(destStat, sourceStat) {
|
|
return (destStat.mode & PERMISSION_MASK) === (sourceStat.mode & PERMISSION_MASK);
|
|
}
|
|
|
|
// Metadata is reconciled even when the content copy is skipped: the quick check
|
|
// exists to avoid rewriting bytes, not to leave a destination that disagrees
|
|
// with the source about permissions. A source that revokes the executable bit
|
|
// without touching size or mtime must not leave an executable behind.
|
|
function syncMetadata(destPath, destStat, sourceStat, stats) {
|
|
const isLink = destStat.isSymbolicLink();
|
|
let changed = false;
|
|
|
|
if (!isLink && !samePermissions(destStat, sourceStat)) {
|
|
chmodSync(destPath, sourceStat.mode & PERMISSION_MASK);
|
|
changed = true;
|
|
}
|
|
|
|
if (!sameModifiedTime(destStat, sourceStat)) {
|
|
if (isLink) {
|
|
lutimesSync(destPath, sourceStat.atime, sourceStat.mtime);
|
|
} else {
|
|
utimesSync(destPath, sourceStat.atime, sourceStat.mtime);
|
|
}
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) stats.metadata++;
|
|
return changed;
|
|
}
|
|
|
|
function copyFile(sourcePath, destPath, sourceStat, stats) {
|
|
const destStat = lstatSync(destPath, { throwIfNoEntry: false });
|
|
|
|
if (
|
|
destStat &&
|
|
destStat.isFile() &&
|
|
destStat.size === sourceStat.size &&
|
|
sameModifiedTime(destStat, sourceStat)
|
|
) {
|
|
syncMetadata(destPath, destStat, sourceStat, stats);
|
|
return;
|
|
}
|
|
|
|
if (destStat && !destStat.isFile()) {
|
|
rmSync(destPath, { recursive: true, force: true });
|
|
}
|
|
|
|
copyFileSync(sourcePath, destPath);
|
|
chmodSync(destPath, sourceStat.mode & PERMISSION_MASK);
|
|
utimesSync(destPath, sourceStat.atime, sourceStat.mtime);
|
|
stats.copied++;
|
|
}
|
|
|
|
function copySymlink(sourcePath, destPath, sourceStat, stats) {
|
|
const target = readlinkSync(sourcePath);
|
|
const destStat = lstatSync(destPath, { throwIfNoEntry: false });
|
|
|
|
if (destStat && destStat.isSymbolicLink() && readlinkSync(destPath) === target) {
|
|
syncMetadata(destPath, destStat, sourceStat, stats);
|
|
return;
|
|
}
|
|
|
|
if (destStat) {
|
|
rmSync(destPath, { recursive: true, force: true });
|
|
}
|
|
|
|
symlinkSync(target, destPath);
|
|
lutimesSync(destPath, sourceStat.atime, sourceStat.mtime);
|
|
stats.copied++;
|
|
}
|
|
|
|
function mirrorInto(sourceDir, destDir, relativeBase, rules, stats) {
|
|
mkdirSync(destDir, { recursive: true });
|
|
|
|
const sourceEntries = readdirSync(sourceDir, { withFileTypes: true }).filter(
|
|
entry => !isExcluded(rules, joinRelative(relativeBase, entry.name), entry.isDirectory())
|
|
);
|
|
const sourceNames = new Set(sourceEntries.map(entry => entry.name));
|
|
|
|
// `--delete`, including its receiver-side protection: excluded paths (.git,
|
|
// node_modules, plugin/data, ...) are left alone rather than wiped.
|
|
for (const entry of readdirSync(destDir, { withFileTypes: true })) {
|
|
if (sourceNames.has(entry.name)) continue;
|
|
if (isExcluded(rules, joinRelative(relativeBase, entry.name), entry.isDirectory())) continue;
|
|
rmSync(path.join(destDir, entry.name), { recursive: true, force: true });
|
|
stats.deleted++;
|
|
}
|
|
|
|
for (const entry of sourceEntries) {
|
|
const sourcePath = path.join(sourceDir, entry.name);
|
|
const destPath = path.join(destDir, entry.name);
|
|
|
|
if (entry.isSymbolicLink()) {
|
|
copySymlink(sourcePath, destPath, lstatSync(sourcePath), stats);
|
|
continue;
|
|
}
|
|
|
|
if (entry.isDirectory()) {
|
|
const destStat = lstatSync(destPath, { throwIfNoEntry: false });
|
|
if (destStat && !destStat.isDirectory()) {
|
|
rmSync(destPath, { recursive: true, force: true });
|
|
}
|
|
mirrorInto(sourcePath, destPath, joinRelative(relativeBase, entry.name), rules, stats);
|
|
continue;
|
|
}
|
|
|
|
copyFile(sourcePath, destPath, lstatSync(sourcePath), stats);
|
|
}
|
|
|
|
// Directories are reconciled last: writing their children bumps the
|
|
// destination mtime, and tightening permissions before the writes would lock
|
|
// the mirror out of its own target.
|
|
syncMetadata(destDir, lstatSync(destDir), lstatSync(sourceDir), stats);
|
|
|
|
return stats;
|
|
}
|
|
|
|
// Canonical form of a path whose tail may not exist yet: realpath the deepest
|
|
// existing ancestor, then re-append the missing segments. Symlinked roots
|
|
// (macOS's /tmp -> /private/tmp) must compare equal to their targets or the
|
|
// overlap guard below would miss a nested pair spelled two different ways.
|
|
function canonicalizePath(dir) {
|
|
let current = path.resolve(dir);
|
|
const missingSegments = [];
|
|
for (;;) {
|
|
try {
|
|
return path.join(realpathSync(current), ...missingSegments);
|
|
} catch {
|
|
const parent = path.dirname(current);
|
|
if (parent === current) return path.join(current, ...missingSegments);
|
|
missingSegments.unshift(path.basename(current));
|
|
current = parent;
|
|
}
|
|
}
|
|
}
|
|
|
|
function isPathInside(parent, child) {
|
|
const relation = path.relative(parent, child);
|
|
return relation !== '' && relation !== '..' && !relation.startsWith(`..${path.sep}`) && !path.isAbsolute(relation);
|
|
}
|
|
|
|
// `--delete` makes overlapping roots destructive: a source nested inside the
|
|
// destination is absent from the destination's own listing, so the receiver
|
|
// cleanup would wipe the source before it is ever read. Refuse identical and
|
|
// ancestor/descendant pairs before any filesystem mutation.
|
|
function assertDisjointRoots(sourceDir, destDir) {
|
|
// Windows and default macOS filesystems are case-insensitive.
|
|
const fold = process.platform === 'linux' ? (p) => p : (p) => p.toLowerCase();
|
|
const source = fold(canonicalizePath(sourceDir));
|
|
const dest = fold(canonicalizePath(destDir));
|
|
if (source === dest) {
|
|
throw new Error(`mirrorDirectory: source and destination are the same directory: ${sourceDir}`);
|
|
}
|
|
if (isPathInside(dest, source)) {
|
|
throw new Error(`mirrorDirectory: source ${sourceDir} is inside destination ${destDir}`);
|
|
}
|
|
if (isPathInside(source, dest)) {
|
|
throw new Error(`mirrorDirectory: destination ${destDir} is inside source ${sourceDir}`);
|
|
}
|
|
}
|
|
|
|
function mirrorDirectory(sourceDir, destDir, options = {}) {
|
|
assertDisjointRoots(sourceDir, destDir);
|
|
const rules = compileExcludes(options.exclude || []);
|
|
return mirrorInto(sourceDir, destDir, '', rules, { copied: 0, metadata: 0, deleted: 0 });
|
|
}
|
|
|
|
module.exports = { mirrorDirectory, compileExcludes, isExcluded };
|