feat(sync): add git vault layer (§5) and the Docmost->vault pull cycle (§6)

Turn the read-only mirror into a git-backed pull cycle. Read-only toward Docmost.

- git.ts (VaultGit): system-git wrapper, all ops cwd=vaultPath (vault is its own
  repo under data/vault, never the source repo); ensureRepo/branches main+docmost,
  commit with provenance (author/committer identity + Docmost-Sync-Source trailer,
  §7.3), merge with conflict surfacing (no auto-resolve, §9), isMergeInProgress;
  GIT_DIR/GIT_WORK_TREE stripped from env (§12 cwd isolation)
- stabilize.ts: normalize-on-write (one export->import->export fixpoint pass, §11)
- reconcile.ts: pure planReconciliation (add/update/move/delete by pageId) +
  decideAbsenceDeletions gate
- pull.ts: write/commit on docmost -> merge into main; listSpaceTree completeness
  signal suppresses absence-deletions on a partial fetch (§8); mass-delete guard;
  merge-in-progress guard makes re-runs converge (§12); move old-path removal only
  on successful write
- docmost-client: listSpaceTree({pages, complete}) without touching the 1:1-copied
  enumerateSpacePages
- tests: reconcile planner + decideAbsenceDeletions, VaultGit incl. real temp-repo
  merge conflict, listSpaceTree completeness (586 green)

Push to a git remote and the FS->Docmost direction are deferred to the next increment.
This commit is contained in:
vvzvlad
2026-06-16 23:57:50 +03:00
parent 4b34f4d30a
commit 531b320776
8 changed files with 1594 additions and 47 deletions

292
test/git.test.ts Normal file
View File

@@ -0,0 +1,292 @@
import { execFile } from 'node:child_process';
import { 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 {
VaultGit,
BOT_AUTHOR_NAME,
BOT_AUTHOR_EMAIL,
buildCommitMessage,
} 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('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('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('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']));
});
});