fix(sync): robust git coupling — non-ASCII paths, config neutralization, runtime git
Address git-integration fragility (output is not parsed for control flow; we rely on exit codes + plumbing — but porcelain BEHAVIOR is config-sensitive, and the runtime image lacked git). - listTrackedFiles: `git -c core.quotepath=false ls-files -z` + NUL split — fixes Cyrillic/UTF-8 vault filenames being returned octal-escaped/quoted - Dockerfile: install git (node:22-slim ships none; the daemon shells out at runtime) - VaultGit env: LC_ALL=C/LANG=C, GIT_PAGER=cat, GIT_TERMINAL_PROMPT=0; keep stripping GIT_DIR/GIT_WORK_TREE (cwd-isolation, §12) - ensureRepo local config: core.autocrlf=false + core.safecrlf=false (protect §11 byte-stability from a global autocrlf=true), commit.gpgsign=false, and core.attributesFile=/dev/null (neutralize a global clean/smudge filter that would rewrite the stored blob); commit uses --no-verify (skip injected hooks) - assertGitAvailable() preflight: clear error if the git binary is missing - tests: Cyrillic listTrackedFiles, LF byte-preservation of the stored blob, local-config neutralization incl. attributesFile (590+ green)
This commit is contained in:
112
test/git.test.ts
112
test/git.test.ts
@@ -1,5 +1,5 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
@@ -106,6 +106,67 @@ describe('VaultGit (integration; temp repo)', () => {
|
||||
expect(count.trim()).toBe('1');
|
||||
});
|
||||
|
||||
it('ensureRepo neutralizes correctness-affecting LOCAL config', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// These LOCAL values neutralize a hostile GLOBAL/system config that would
|
||||
// otherwise change porcelain BEHAVIOR and corrupt the vault (SPEC §11 for
|
||||
// core.autocrlf; gpgsign/safecrlf for the headless daemon).
|
||||
const localConfig = async (key: string): Promise<string> => {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['config', '--local', '--get', key],
|
||||
{ cwd: vault },
|
||||
);
|
||||
return stdout.trim();
|
||||
};
|
||||
expect(await localConfig('core.autocrlf')).toBe('false');
|
||||
expect(await localConfig('commit.gpgsign')).toBe('false');
|
||||
expect(await localConfig('core.safecrlf')).toBe('false');
|
||||
expect(await localConfig('core.attributesFile')).toBe('/dev/null');
|
||||
|
||||
// Idempotent: a second run leaves the same single values (no duplicates).
|
||||
await git.ensureRepo();
|
||||
expect(await localConfig('core.autocrlf')).toBe('false');
|
||||
expect(await localConfig('commit.gpgsign')).toBe('false');
|
||||
expect(await localConfig('core.safecrlf')).toBe('false');
|
||||
});
|
||||
|
||||
it('preserves LF bytes verbatim on commit (SPEC §11: autocrlf=false)', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// Write content with explicit LF line endings. With a hostile
|
||||
// core.autocrlf=true git would translate these to CRLF in the stored blob,
|
||||
// breaking the byte-stable round-trip invariant. ensureRepo pins
|
||||
// core.autocrlf=false locally, so the stored bytes must round-trip exactly.
|
||||
const fileName = 'lf.md';
|
||||
const content = 'line1\nline2\nline3\n';
|
||||
await writeFile(join(vault, fileName), content, 'utf8');
|
||||
await git.stageAll();
|
||||
const made = await git.commit('add LF file', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
});
|
||||
expect(made).toBe(true);
|
||||
|
||||
// Read the STORED blob (not the worktree file) and assert verbatim bytes:
|
||||
// still LF-only, no CRLF translation.
|
||||
const { stdout: stored } = await execFileAsync(
|
||||
'git',
|
||||
['--no-pager', 'show', `HEAD:${fileName}`],
|
||||
{ cwd: vault, encoding: 'buffer' },
|
||||
);
|
||||
const storedBuf = stored as unknown as Buffer;
|
||||
expect(storedBuf.includes(Buffer.from('\r\n'))).toBe(false);
|
||||
expect(storedBuf.toString('utf8')).toBe(content);
|
||||
});
|
||||
|
||||
it('ensureBranch creates the docmost branch from main', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
@@ -289,4 +350,53 @@ describe('VaultGit (integration; temp repo)', () => {
|
||||
const all = await git.listTrackedFiles();
|
||||
expect(new Set(all)).toEqual(new Set(['keep.md', 'note.txt']));
|
||||
});
|
||||
|
||||
it('listTrackedFiles returns RAW UTF-8 Cyrillic paths (not octal-escaped/quoted)', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// The target wiki is Russian, so file names contain Cyrillic. With git's
|
||||
// DEFAULT core.quotepath=true these come back as `"\320\232..."` from
|
||||
// ls-files; `listTrackedFiles` must return them verbatim as UTF-8.
|
||||
const topName = 'Колонка.md';
|
||||
const nestedDir = 'Раздел';
|
||||
const nestedName = 'Подстраница.md';
|
||||
await writeFile(join(vault, topName), 'top\n', 'utf8');
|
||||
await mkdir(join(vault, nestedDir), { recursive: true });
|
||||
await writeFile(join(vault, nestedDir, nestedName), 'nested\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('add cyrillic files', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
});
|
||||
|
||||
const md = await git.listTrackedFiles('*.md');
|
||||
// Exact UTF-8 names, forward-slash separated for the nested one — NOT an
|
||||
// escaped/quoted form like `"\320\232..."`.
|
||||
expect(new Set(md)).toEqual(
|
||||
new Set([topName, `${nestedDir}/${nestedName}`]),
|
||||
);
|
||||
// Guard explicitly against the quotepath regression: no entry is quoted or
|
||||
// contains a backslash escape sequence.
|
||||
for (const p of md) {
|
||||
expect(p.startsWith('"')).toBe(false);
|
||||
expect(p.includes('\\')).toBe(false);
|
||||
}
|
||||
|
||||
// No-glob listing also returns the raw Cyrillic names.
|
||||
const all = await git.listTrackedFiles();
|
||||
expect(all).toContain(topName);
|
||||
expect(all).toContain(`${nestedDir}/${nestedName}`);
|
||||
});
|
||||
|
||||
it('assertGitAvailable resolves when git is present', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
// No repo needed: it only probes `git --version` (and the vault dir need
|
||||
// not even exist yet).
|
||||
await expect(git.assertGitAvailable()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user