Address a code review of the git-hardening changes. - single runRaw primitive: every git invocation funnels through it; run() is a thin throw+trim wrapper; the two direct execFileAsync bypasses (commitRaw, assertGitAvailable) removed; one unified error format - `-c core.quotepath=false` is now the argv baseline for ALL commands (was only listTrackedFiles) — removes the latent quoting asymmetry on ls-files -u / diff --name-only; persisted LOCAL config (autocrlf/safecrlf/gpgsign/ attributesFile) kept as-is in ensureRepo - preserve spawn-error message (ENOENT): use `||` not `??` (promisified execFile sets stderr to "" on spawn failure) - contextual error when pinning vault git config; module/vaultGitEnv docs corrected - README: require a system git binary on PATH for local runs - tests: --no-verify honored (failing pre-commit hook), vaultGitEnv pins, core.attributesFile=/dev/null neutralization (593 green)
477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
import { chmod } from 'node:fs/promises';
|
|
import {
|
|
VaultGit,
|
|
BOT_AUTHOR_NAME,
|
|
BOT_AUTHOR_EMAIL,
|
|
buildCommitMessage,
|
|
vaultGitEnv,
|
|
} from '../src/git.js';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/** True if a usable `git` binary is on PATH (skip the suite otherwise). */
|
|
async function gitAvailable(): Promise<boolean> {
|
|
try {
|
|
await execFileAsync('git', ['--version']);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Read the full commit message of HEAD (subject + body) in a repo dir. */
|
|
async function headMessage(dir: string): Promise<string> {
|
|
const { stdout } = await execFileAsync(
|
|
'git',
|
|
['--no-pager', 'log', '-1', '--pretty=%B'],
|
|
{ cwd: dir },
|
|
);
|
|
return stdout.trim();
|
|
}
|
|
|
|
/** Read the author "Name <email>" of HEAD in a repo dir. */
|
|
async function headAuthor(dir: string): Promise<string> {
|
|
const { stdout } = await execFileAsync(
|
|
'git',
|
|
['--no-pager', 'log', '-1', '--pretty=%an <%ae>'],
|
|
{ cwd: dir },
|
|
);
|
|
return stdout.trim();
|
|
}
|
|
|
|
describe('buildCommitMessage (pure)', () => {
|
|
it('returns the bare subject when there are no trailers', () => {
|
|
expect(buildCommitMessage('subject')).toBe('subject');
|
|
expect(buildCommitMessage('subject', [])).toBe('subject');
|
|
});
|
|
|
|
it('appends trailers separated from the subject by a blank line', () => {
|
|
expect(buildCommitMessage('subject', ['Docmost-Sync-Source: docmost'])).toBe(
|
|
'subject\n\nDocmost-Sync-Source: docmost',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('vaultGitEnv (pure)', () => {
|
|
it('pins locale, pager and prompt, and strips GIT_DIR/GIT_WORK_TREE', () => {
|
|
// Seed inputs that MUST be neutralized/stripped: a redirecting GIT_DIR and
|
|
// GIT_WORK_TREE would defeat the cwd-isolation guarantee (SPEC §12).
|
|
process.env.GIT_DIR = '/somewhere/else/.git';
|
|
process.env.GIT_WORK_TREE = '/somewhere/else';
|
|
try {
|
|
const env = vaultGitEnv();
|
|
// Locale-independent output.
|
|
expect(env.LC_ALL).toBe('C');
|
|
expect(env.LANG).toBe('C');
|
|
// Never page, never block on an interactive prompt.
|
|
expect(env.GIT_PAGER).toBe('cat');
|
|
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
|
|
// The redirecting vars are removed regardless of what process.env held.
|
|
expect(env.GIT_DIR).toBeUndefined();
|
|
expect(env.GIT_WORK_TREE).toBeUndefined();
|
|
} finally {
|
|
delete process.env.GIT_DIR;
|
|
delete process.env.GIT_WORK_TREE;
|
|
}
|
|
});
|
|
|
|
it('passes through caller extras (e.g. author/committer identity)', () => {
|
|
const env = vaultGitEnv({ GIT_AUTHOR_NAME: 'X', GIT_AUTHOR_EMAIL: 'x@y' });
|
|
expect(env.GIT_AUTHOR_NAME).toBe('X');
|
|
expect(env.GIT_AUTHOR_EMAIL).toBe('x@y');
|
|
// Still strips the redirecting vars even with extras present.
|
|
expect(env.GIT_DIR).toBeUndefined();
|
|
expect(env.GIT_WORK_TREE).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('VaultGit (integration; temp repo)', () => {
|
|
let available = false;
|
|
let dir: string;
|
|
|
|
beforeAll(async () => {
|
|
available = await gitAvailable();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (dir) {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/** Make a fresh temp dir for one test (under the OS tmpdir, NOT the repo). */
|
|
async function freshDir(): Promise<string> {
|
|
dir = await mkdtemp(join(tmpdir(), 'docmost-vault-'));
|
|
return dir;
|
|
}
|
|
|
|
it('ensureRepo creates .git + main + an initial commit', async () => {
|
|
if (!available) return; // skip gracefully when git is unavailable
|
|
const vault = await freshDir();
|
|
const git = new VaultGit(vault);
|
|
await git.ensureRepo();
|
|
|
|
// It is a git work-tree now.
|
|
const { stdout: insideWt } = await execFileAsync(
|
|
'git',
|
|
['rev-parse', '--is-inside-work-tree'],
|
|
{ cwd: vault },
|
|
);
|
|
expect(insideWt.trim()).toBe('true');
|
|
|
|
// On `main`.
|
|
expect(await git.currentBranch()).toBe('main');
|
|
|
|
// Has the initial commit.
|
|
expect(await headMessage(vault)).toBe('init vault');
|
|
|
|
// Idempotent: calling again does not create a second commit.
|
|
await git.ensureRepo();
|
|
const { stdout: count } = await execFileAsync(
|
|
'git',
|
|
['rev-list', '--count', 'HEAD'],
|
|
{ cwd: vault },
|
|
);
|
|
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();
|
|
const git = new VaultGit(vault);
|
|
await git.ensureRepo();
|
|
|
|
expect(await git.branchExists('docmost')).toBe(false);
|
|
await git.ensureBranch('docmost', 'main');
|
|
expect(await git.branchExists('docmost')).toBe(true);
|
|
|
|
// Idempotent.
|
|
await git.ensureBranch('docmost', 'main');
|
|
expect(await git.branchExists('docmost')).toBe(true);
|
|
});
|
|
|
|
it('commit writes a commit with the provenance trailer and the bot identity', async () => {
|
|
if (!available) return;
|
|
const vault = await freshDir();
|
|
const git = new VaultGit(vault);
|
|
await git.ensureRepo();
|
|
|
|
await writeFile(join(vault, 'page.md'), 'hello\n', 'utf8');
|
|
await git.stageAll();
|
|
const made = await git.commit('docmost: sync 1 page(s)', {
|
|
authorName: BOT_AUTHOR_NAME,
|
|
authorEmail: BOT_AUTHOR_EMAIL,
|
|
trailers: ['Docmost-Sync-Source: docmost'],
|
|
});
|
|
expect(made).toBe(true);
|
|
|
|
const msg = await headMessage(vault);
|
|
expect(msg).toContain('docmost: sync 1 page(s)');
|
|
expect(msg).toContain('Docmost-Sync-Source: docmost');
|
|
|
|
const author = await headAuthor(vault);
|
|
expect(author).toBe(`${BOT_AUTHOR_NAME} <${BOT_AUTHOR_EMAIL}>`);
|
|
|
|
// The trailer is parseable by git itself.
|
|
const { stdout: trailers } = await execFileAsync(
|
|
'git',
|
|
['--no-pager', 'log', '-1', '--pretty=%(trailers:key=Docmost-Sync-Source,valueonly)'],
|
|
{ cwd: vault },
|
|
);
|
|
expect(trailers.trim()).toBe('docmost');
|
|
});
|
|
|
|
it('commit is a no-op when there is nothing to commit', async () => {
|
|
if (!available) return;
|
|
const vault = await freshDir();
|
|
const git = new VaultGit(vault);
|
|
await git.ensureRepo();
|
|
|
|
await git.stageAll(); // nothing changed since the init commit
|
|
const made = await git.commit('docmost: sync 0 page(s)', {
|
|
authorName: BOT_AUTHOR_NAME,
|
|
authorEmail: BOT_AUTHOR_EMAIL,
|
|
trailers: ['Docmost-Sync-Source: docmost'],
|
|
});
|
|
expect(made).toBe(false);
|
|
|
|
// Still exactly one commit (the init one).
|
|
const { stdout: count } = await execFileAsync(
|
|
'git',
|
|
['rev-list', '--count', 'HEAD'],
|
|
{ cwd: vault },
|
|
);
|
|
expect(count.trim()).toBe('1');
|
|
});
|
|
|
|
it('commit honors --no-verify (a failing pre-commit hook does not block it)', async () => {
|
|
if (!available) return;
|
|
const vault = await freshDir();
|
|
const git = new VaultGit(vault);
|
|
await git.ensureRepo();
|
|
|
|
// Commit count BEFORE: just the init commit.
|
|
const countBefore = async (): Promise<number> => {
|
|
const { stdout } = await execFileAsync(
|
|
'git',
|
|
['rev-list', '--count', 'HEAD'],
|
|
{ cwd: vault },
|
|
);
|
|
return Number(stdout.trim());
|
|
};
|
|
const before = await countBefore();
|
|
|
|
// Install an EXECUTABLE pre-commit hook that always fails. Without
|
|
// `--no-verify`, `git commit` would run it, the hook would `exit 1`, and the
|
|
// commit would be ABORTED. So this test fails (no commit created, made !==
|
|
// true) the moment `--no-verify` is removed from commitRaw.
|
|
const hookPath = join(vault, '.git', 'hooks', 'pre-commit');
|
|
await writeFile(hookPath, '#!/bin/sh\nexit 1\n', 'utf8');
|
|
await chmod(hookPath, 0o755);
|
|
|
|
await writeFile(join(vault, 'hooked.md'), 'content\n', 'utf8');
|
|
await git.stageAll();
|
|
const made = await git.commit('commit past a failing hook', {
|
|
authorName: BOT_AUTHOR_NAME,
|
|
authorEmail: BOT_AUTHOR_EMAIL,
|
|
trailers: ['Docmost-Sync-Source: docmost'],
|
|
});
|
|
|
|
// The commit was reported made AND actually landed (HEAD advanced by one).
|
|
expect(made).toBe(true);
|
|
expect(await countBefore()).toBe(before + 1);
|
|
expect(await headMessage(vault)).toContain('commit past a failing hook');
|
|
});
|
|
|
|
it('merge fast-forwards main to docmost', async () => {
|
|
if (!available) return;
|
|
const vault = await freshDir();
|
|
const git = new VaultGit(vault);
|
|
await git.ensureRepo();
|
|
await git.ensureBranch('docmost', 'main');
|
|
|
|
// Commit a file on docmost.
|
|
await git.checkout('docmost');
|
|
await writeFile(join(vault, 'a.md'), 'a\n', 'utf8');
|
|
await git.stageAll();
|
|
await git.commit('docmost: sync 1 page(s)', {
|
|
authorName: BOT_AUTHOR_NAME,
|
|
authorEmail: BOT_AUTHOR_EMAIL,
|
|
trailers: ['Docmost-Sync-Source: docmost'],
|
|
});
|
|
|
|
// main has not diverged, so the merge is a clean fast-forward.
|
|
await git.checkout('main');
|
|
const res = await git.merge('docmost');
|
|
expect(res.ok).toBe(true);
|
|
expect(res.conflict).toBe(false);
|
|
|
|
// main now contains the file and the docmost commit.
|
|
const tracked = await git.listTrackedFiles();
|
|
expect(tracked).toContain('a.md');
|
|
expect(await headMessage(vault)).toContain('docmost: sync 1 page(s)');
|
|
});
|
|
|
|
it('merge surfaces a conflict distinctly (no auto-resolve)', async () => {
|
|
if (!available) return;
|
|
const vault = await freshDir();
|
|
const git = new VaultGit(vault);
|
|
await git.ensureRepo();
|
|
await git.ensureBranch('docmost', 'main');
|
|
|
|
// Divergent edits to the SAME file on both branches -> real conflict.
|
|
await git.checkout('docmost');
|
|
await writeFile(join(vault, 'c.md'), 'from docmost\n', 'utf8');
|
|
await git.stageAll();
|
|
await git.commit('docmost edit', {
|
|
authorName: BOT_AUTHOR_NAME,
|
|
authorEmail: BOT_AUTHOR_EMAIL,
|
|
});
|
|
|
|
await git.checkout('main');
|
|
await writeFile(join(vault, 'c.md'), 'from main\n', 'utf8');
|
|
await git.stageAll();
|
|
await git.commit('main edit', {
|
|
authorName: 'Human',
|
|
authorEmail: 'human@local',
|
|
});
|
|
|
|
const res = await git.merge('docmost');
|
|
expect(res.ok).toBe(false);
|
|
expect(res.conflict).toBe(true);
|
|
});
|
|
|
|
it('isMergeInProgress is false on a clean repo and true mid-merge', async () => {
|
|
if (!available) return;
|
|
const vault = await freshDir();
|
|
const git = new VaultGit(vault);
|
|
await git.ensureRepo();
|
|
await git.ensureBranch('docmost', 'main');
|
|
|
|
// Clean repo, no merge in progress.
|
|
expect(await git.isMergeInProgress()).toBe(false);
|
|
|
|
// Create a REAL conflict: divergent edits to the same file on both branches.
|
|
await git.checkout('docmost');
|
|
await writeFile(join(vault, 'c.md'), 'from docmost\n', 'utf8');
|
|
await git.stageAll();
|
|
await git.commit('docmost edit', {
|
|
authorName: BOT_AUTHOR_NAME,
|
|
authorEmail: BOT_AUTHOR_EMAIL,
|
|
});
|
|
|
|
await git.checkout('main');
|
|
await writeFile(join(vault, 'c.md'), 'from main\n', 'utf8');
|
|
await git.stageAll();
|
|
await git.commit('main edit', {
|
|
authorName: 'Human',
|
|
authorEmail: 'human@local',
|
|
});
|
|
|
|
// Merge conflicts -> the repo is now left mid-merge.
|
|
const res = await git.merge('docmost');
|
|
expect(res.conflict).toBe(true);
|
|
expect(await git.isMergeInProgress()).toBe(true);
|
|
|
|
// Aborting the merge clears the in-progress state again.
|
|
await execFileAsync('git', ['--no-pager', 'merge', '--abort'], { cwd: vault });
|
|
expect(await git.isMergeInProgress()).toBe(false);
|
|
});
|
|
|
|
it('listTrackedFiles supports a glob and returns forward-slash paths', async () => {
|
|
if (!available) return;
|
|
const vault = await freshDir();
|
|
const git = new VaultGit(vault);
|
|
await git.ensureRepo();
|
|
|
|
await writeFile(join(vault, 'keep.md'), 'k\n', 'utf8');
|
|
await writeFile(join(vault, 'note.txt'), 't\n', 'utf8');
|
|
await git.stageAll();
|
|
await git.commit('add files', {
|
|
authorName: BOT_AUTHOR_NAME,
|
|
authorEmail: BOT_AUTHOR_EMAIL,
|
|
});
|
|
|
|
const md = await git.listTrackedFiles('*.md');
|
|
expect(md).toEqual(['keep.md']);
|
|
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();
|
|
});
|
|
});
|