Merge pull request #1320 from vrtmrz/fix/cli-storage-case-detection

Fix CLI filesystem case-sensitivity detection
This commit is contained in:
vorotamoroz authored and GitHub committed 2026-11-05 22:52:32 +09:00
commit 8b0f5fbd48
6 files changed
+145 -1

No files matched your search

+38
View File
@@ -103,6 +103,44 @@ describe("CLI database preparation", () => {
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`); await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
} }
it.each([
{ handleFilenameCaseSensitive: true, pathNormalizationVersion: 0 as const },
{ handleFilenameCaseSensitive: true, pathNormalizationVersion: 1 as const },
{ handleFilenameCaseSensitive: false, pathNormalizationVersion: 0 as const },
{ handleFilenameCaseSensitive: false, pathNormalizationVersion: 1 as const },
])(
"writes Vault files with case sensitivity $handleFilenameCaseSensitive and path identity $pathNormalizationVersion",
async (settings) => {
await writeFile(
settingsPath,
JSON.stringify({ ...createDefaultCliSettings(), ...settings, isConfigured: true })
);
const writes: boolean[] = [];
let failure: unknown;
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
try {
writes.push(
await core.serviceModules.storageAccess.writeFileAuto("notes/Remote.md", "remote content")
);
writes.push(
await core.serviceModules.storageAccess.writeFileAuto("notes/Remote.md", "updated content")
);
} catch (error) {
failure = error;
}
return false;
});
await start("ls", runner);
expect(runner).toHaveBeenCalledOnce();
expect(failure).toBeUndefined();
expect(writes).toEqual([true, true]);
expect(await readFile(join(vaultPath, "notes/Remote.md"), "utf8")).toBe("updated content");
expect(await readFile(join(vaultPath, "notes/local.md"), "utf8")).toBe("local content");
}
);
it.each([ it.each([
{ separator: "/", platform: "POSIX" }, { separator: "/", platform: "POSIX" },
{ separator: "\\", platform: "Windows" }, { separator: "\\", platform: "Windows" },
+1 -1
View File
@@ -12,7 +12,7 @@
"buildRun": "npm run build && npm run cli --", "buildRun": "npm run build && npm run cli --",
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..", "build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
"check": "tsc -p tsconfig.json", "check": "tsc -p tsconfig.json",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/journalSkip.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts", "test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/services/storageCaseSensitivity.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/journalSkip.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh", "test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh", "test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh", "test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
+5
View File
@@ -22,6 +22,7 @@ import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/
import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService"; import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService";
import { NodeKeyValueDBService } from "./NodeKeyValueDBService"; import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
import { createNodeLocalStorage } from "./NodeLocalStorage"; import { createNodeLocalStorage } from "./NodeLocalStorage";
import { isStorageCaseInsensitive } from "./storageCaseSensitivity";
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService"; import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { fs, path as nodePath } from "@vrtmrz/livesync-commonlib/node"; import { fs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
@@ -135,6 +136,10 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
settingService: setting, settingService: setting,
APIService: API, APIService: API,
}); });
let storageInsensitive: boolean | undefined;
vault.isStorageInsensitive.setHandler(
() => (storageInsensitive ??= isStorageCaseInsensitive(context.runtimeVaultPath))
);
const scopeDirectory = () => runtimeScopeDirectory(basePath, vault.getVaultName(), context.runtimeVaultPath); const scopeDirectory = () => runtimeScopeDirectory(basePath, vault.getVaultName(), context.runtimeVaultPath);
const test = new InjectableTestService(context); const test = new InjectableTestService(context);
const databaseEvents = new InjectableDatabaseEventService(context); const databaseEvents = new InjectableDatabaseEventService(context);
@@ -0,0 +1,16 @@
import { fs, path } from "@vrtmrz/livesync-commonlib/node";
export function isStorageCaseInsensitive(vaultPath: string): boolean {
// Probe with an empty directory on the Vault filesystem. The dot-prefixed name is
// excluded by the CLI watcher and synchronisation filters; finally removes it after detection.
const probePath = fs.mkdtempSync(path.join(vaultPath, ".livesync-case-"));
try {
const original = fs.statSync(probePath);
const alternate = fs.statSync(path.join(vaultPath, path.basename(probePath).toUpperCase()), {
throwIfNoEntry: false,
});
return alternate !== undefined && original.dev === alternate.dev && original.ino === alternate.ino;
} finally {
fs.rmdirSync(probePath);
}
}
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import { isStorageCaseInsensitive } from "./storageCaseSensitivity";
import { NodeServiceContext, NodeServiceHub } from "./NodeServiceHub";
vi.mock("@vrtmrz/livesync-commonlib/node", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/node")>();
return { ...actual, fs: { ...actual.fs } };
});
describe("CLI storage case sensitivity", () => {
let directory: string;
let vaultPath: string;
beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-storage-case-"));
vaultPath = path.join(directory, "vault");
fs.mkdirSync(vaultPath);
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(directory, { recursive: true, force: true });
});
it("matches the Vault filesystem and leaves existing files untouched", () => {
fs.writeFileSync(path.join(vaultPath, "Note.md"), "local content");
const expected = fs.existsSync(path.join(vaultPath, "NOTE.MD"));
expect(isStorageCaseInsensitive(vaultPath)).toBe(expected);
expect(fs.readdirSync(vaultPath)).toEqual(["Note.md"]);
expect(fs.readFileSync(path.join(vaultPath, "Note.md"), "utf8")).toBe("local content");
});
it("recognises an alternate spelling of the same filesystem entry", () => {
const entry = fs.statSync(vaultPath);
vi.spyOn(fs, "statSync").mockReturnValue(entry);
expect(isStorageCaseInsensitive(vaultPath)).toBe(true);
expect(fs.readdirSync(vaultPath)).toEqual([]);
});
it("does not mistake a different entry for the probe", () => {
const original = fs.statSync(vaultPath);
const other = fs.statSync(directory);
vi.spyOn(fs, "statSync").mockReturnValueOnce(original).mockReturnValueOnce(other);
expect(isStorageCaseInsensitive(vaultPath)).toBe(false);
expect(fs.readdirSync(vaultPath)).toEqual([]);
});
it("reports inspection failures and removes its probe", () => {
const failure = Object.assign(new Error("Cannot inspect the Vault"), { code: "EACCES" });
vi.spyOn(fs, "statSync").mockImplementation(() => {
throw failure;
});
expect(() => isStorageCaseInsensitive(vaultPath)).toThrow(failure);
expect(fs.readdirSync(vaultPath)).toEqual([]);
});
it("lazily detects the selected Vault once per service hub", () => {
const createProbe = vi.spyOn(fs, "mkdtempSync");
const context = new NodeServiceContext(
directory,
{
readStdin: async () => "",
prompt: async () => "",
writeStdout: () => undefined,
writeStderr: () => undefined,
},
vaultPath
);
const hub = new NodeServiceHub(directory, context);
expect(createProbe).not.toHaveBeenCalled();
const first = hub.vault.isStorageInsensitive();
expect(hub.vault.isStorageInsensitive()).toBe(first);
expect(createProbe).toHaveBeenCalledExactlyOnceWith(path.join(vaultPath, ".livesync-case-"));
expect(fs.readdirSync(vaultPath)).toEqual([]);
});
});
+2
View File
@@ -48,6 +48,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
#### Fixed #### Fixed
- CLI `fetch` and `daemon` can write received files to the Vault when `handleFilenameCaseSensitive` is enabled. The CLI now detects whether the Vault filesystem distinguishes letter case. (#1319)
- Commonlib warnings now appear on stderr without `--debug`, so deliberately skipped Journals and other important notices remain visible during ordinary CLI use. - Commonlib warnings now appear on stderr without `--debug`, so deliberately skipped Journals and other important notices remain visible during ordinary CLI use.
- NFC file lookups and directory scans report permission and I/O failures instead of treating unreadable files as absent or returning a successful partial scan. A scan retains the comparison settings with which it began. - NFC file lookups and directory scans report permission and I/O failures instead of treating unreadable files as absent or returning a successful partial scan. A scan retains the comparison settings with which it began.