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 { 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 { const { stdout } = await execFileAsync( 'git', ['--no-pager', 'log', '-1', '--pretty=%B'], { cwd: dir }, ); return stdout.trim(); } /** Read the author "Name " of HEAD in a repo dir. */ async function headAuthor(dir: string): Promise { 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 { 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'])); }); });