fix: synchronise case-only file renames safely

This commit is contained in:
metrovoc
2026-07-14 10:39:45 +09:00
committed by vorotamoroz
parent ba7ea27d0c
commit 869a893c19
362 changed files with 722 additions and 380 deletions
@@ -36,8 +36,27 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
return this.path.normalisePath(p as string);
}
private async hasExactPathCase(pathStr: string): Promise<boolean> {
try {
const segments = pathStr.split("/").filter((segment) => segment !== "");
let currentPath = this.basePath;
for (const segment of segments) {
const entries = await fs.readdir(currentPath);
if (!entries.includes(segment)) return false;
currentPath = path.join(currentPath, segment);
}
return segments.length > 0;
} catch {
return false;
}
}
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
if (!this.fileCache.has(pathStr) && !(await this.hasExactPathCase(pathStr))) {
this.fileCache.delete(pathStr);
return null;
}
return await this.refreshFile(pathStr);
}
@@ -74,6 +93,15 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
return Array.from(this.fileCache.values());
}
async renameFile(file: NodeFile, newPath: string): Promise<NodeFile> {
const oldPath = file.path;
await this.vault.rename(file, newPath);
this.fileCache.delete(oldPath);
const renamedFile = await this.refreshFile(newPath);
if (!renamedFile) throw new Error(`Could not find renamed file: ${newPath}`);
return renamedFile;
}
async statFromNative(file: NodeFile): Promise<UXStat> {
return file.stat;
}
@@ -103,6 +103,13 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
};
}
async rename(file: NodeFile, newPath: string): Promise<void> {
const targetPath = this.resolvePath(newPath);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.rename(this.resolvePath(file.path), targetPath);
file.path = newPath as FilePath;
}
async delete(file: NodeFile | NodeFolder, force = false): Promise<void> {
const fullPath = this.resolvePath(file.path);
const stat = await fs.stat(fullPath);
@@ -0,0 +1,51 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { FilePath } from "@lib/common/types";
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
import { NodeVaultAdapter } from "./NodeVaultAdapter";
describe("NodeVaultAdapter.rename", () => {
it("changes the directory entry case without changing the content", async () => {
const directory = await mkdtemp(join(tmpdir(), "livesync-case-rename-"));
try {
await writeFile(join(directory, "Calculus.md"), "content", "utf8");
const adapter = new NodeVaultAdapter(directory);
const file = {
path: "Calculus.md" as FilePath,
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
};
await adapter.rename(file, "calculus.md");
expect(await readdir(directory)).toEqual(["calculus.md"]);
expect(await readFile(join(directory, "calculus.md"), "utf8")).toBe("content");
expect(file.path).toBe("calculus.md");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe("NodeFileSystemAdapter path case", () => {
it("finds the stored case and refreshes the cache after a case-only rename", async () => {
const directory = await mkdtemp(join(tmpdir(), "livesync-case-cache-"));
try {
await writeFile(join(directory, "Calculus.md"), "content", "utf8");
const adapter = new NodeFileSystemAdapter(directory);
await expect(adapter.getAbstractFileByPath("calculus.md")).resolves.toBeNull();
const existingFile = await adapter.getAbstractFileByPathInsensitive("calculus.md");
expect(existingFile?.path).toBe("Calculus.md");
if (!existingFile) throw new Error("Expected to find Calculus.md case-insensitively");
const renamedFile = await adapter.renameFile(existingFile, "calculus.md");
expect(renamedFile.path).toBe("calculus.md");
expect((await adapter.getFiles()).map((file) => file.path)).toEqual(["calculus.md"]);
expect(await readdir(directory)).toEqual(["calculus.md"]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});