sync: update from private repo (edc775f2)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-07-06 01:04:12 +00:00
parent 747377bef9
commit b1292e34b2
322 changed files with 28001 additions and 4686 deletions
+21 -17
View File
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto';
import {
existsSync, readFileSync, writeFileSync, mkdirSync, openSync, closeSync, renameSync, statSync,
constants, copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, statSync, unlinkSync,
} from 'node:fs';
import { join, dirname, extname } from 'node:path';
@@ -16,8 +16,8 @@ import { join, dirname, extname } from 'node:path';
* read-modify-write 自体は同期実行(better-sqlite3 / tools は単一スレッド同期)なので、
* 一回の呼び出し内では中断されない。temp+rename により、別エージェントの並行書込で
* 半端な JSON を観測したり last-writer-wins でキーを取りこぼす窓を除去する。
* - 競合コピー名は `openSync(path, 'wx')`(O_EXCL)で予約してから書く。`while(existsSync)`
* スキャンの TOCTOU を排除し、二者が同じ base を同時解決しても別名を得る。
* - 旧版退避先は `copyFileSync(..., COPYFILE_EXCL)` で確保してから元ファイルを消す。
* これにより、二者が同じ base を同時解決しても old/ 内の既存ファイルを上書きしない。
*
* 残存制限(spec §10.3): per-file の書込ロックは持たない。同一成果物への真の同時上書きは
* last-writer-wins のまま。台帳のアトミック性とは別レイヤーの課題。
@@ -66,34 +66,39 @@ export function recordVersion(ws: string, relPath: string, conflictDir: string):
}
export interface WriteResolution {
/** 実際に書き込むべき相対パス(競合時は競合コピー名) */
/** 実際に書き込むべき相対パス */
targetRelPath: string;
/** 競合時に退避した旧ファイルの相対パス */
archivedRelPath?: string;
conflict: boolean;
}
/**
* 競合コピー名を O_EXCL で予約する。`{stem} (競合コピー N){ext}` を N=1 から順に試し、
* 排他作成に成功した最初の名前を返す。空ファイルを予約として作るので、呼び出し側は
* その名前へ後から書き込む(盲目上書きの窓が無い)。
* 既存ファイルを同じ階層の old/ へ退避する。退避名は `{stem}_oldN{ext}`。
* COPYFILE_EXCL により、既存の退避ファイルを上書きしない。
*/
function reserveCompetingCopy(ws: string, relPath: string): string {
function archiveExistingFile(ws: string, relPath: string): string {
const ext = extname(relPath);
const stem = relPath.slice(0, relPath.length - ext.length);
const baseName = relPath.slice(relPath.lastIndexOf('/') + 1);
const stem = baseName.slice(0, baseName.length - ext.length);
const parent = dirname(relPath);
const oldDir = parent === '.' ? 'old' : join(parent, 'old');
const sourceAbs = join(ws, relPath);
// 同一プロセス内の暴走に対する上限(実用上は到達しない)。
for (let n = 1; n < 100000; n++) {
const candidate = `${stem} (競合コピー ${n})${ext}`;
const candidate = join(oldDir, `${stem}_old${n}${ext}`);
const abs = join(ws, candidate);
try {
mkdirSync(dirname(abs), { recursive: true });
const fd = openSync(abs, 'wx'); // O_EXCL: 既存なら EEXIST
closeSync(fd);
copyFileSync(sourceAbs, abs, constants.COPYFILE_EXCL);
unlinkSync(sourceAbs);
return candidate;
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'EEXIST') continue;
throw e;
}
}
throw new Error('reserveCompetingCopy: exhausted candidate names');
throw new Error('archiveExistingFile: exhausted candidate names');
}
/**
@@ -101,7 +106,7 @@ function reserveCompetingCopy(ws: string, relPath: string): string {
* - 対象が存在しない → そのまま書く(競合なし)。
* - 対象が存在し、ディスク版が台帳の記録と一致 → 普通に上書き(競合なし)。
* - 対象が存在するが台帳に記録が無い、または記録と食い違う → 競合。
* O_EXCL で予約した `{name} (競合コピー N).{ext}` を返し、元ファイルは触らない。
* 既存ファイルを同じ階層の `old/{name}_oldN.{ext}` へ退避し、元パスへ新しい内容を書く。
*/
export function resolveWriteTarget(
ws: string,
@@ -115,7 +120,6 @@ export function resolveWriteTarget(
const base = led[relPath];
const diskHash = hash(readFileSync(abs));
if (base && base === diskHash) return { targetRelPath: relPath, conflict: false };
// 競合: O_EXCL で空いている連番のコピー名を予約する(TOCTOU 排除)。
const candidate = reserveCompetingCopy(ws, relPath);
return { targetRelPath: candidate, conflict: true };
const archivedRelPath = archiveExistingFile(ws, relPath);
return { targetRelPath: relPath, archivedRelPath, conflict: true };
}