openpencil/apps/desktop/git/__tests__/git-engine.test.ts
2026-05-03 21:00:00 +08:00

2488 lines
90 KiB
TypeScript

// apps/desktop/git/__tests__/git-engine.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { join, resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { promises as fsp } from 'node:fs';
import { execFile, execFileSync } from 'node:child_process';
import { promisify } from 'node:util';
import {
engineDetect,
engineInit,
engineOpen,
engineBindTrackedFile,
engineListCandidates,
engineClose,
engineStatus,
engineLog,
engineCommit,
engineRestore,
enginePromote,
engineBranchList,
engineBranchCreate,
engineBranchSwitch,
engineBranchDelete,
engineFetch,
enginePull,
enginePush,
engineDiff,
engineBranchMerge,
engineResolveConflict,
engineApplyMerge,
engineAbortMerge,
engineRemoteGet,
engineRemoteSet,
} from '../git-engine';
import { clearAllSessions, sessionCount } from '../repo-session';
import { mkTempDir, writeOpFile, mkSubdir } from './test-helpers';
const execFileAsync = promisify(execFile);
// Synchronous availability probe at module load — same pattern as
// git-sys-real.test.ts. vitest's it.skipIf() reads its predicate at
// test-collection time, before any beforeEach hook has run.
let systemGitAvailable: boolean;
try {
execFileSync('git', ['--version'], { stdio: 'ignore', timeout: 5000 });
systemGitAvailable = true;
} catch {
systemGitAvailable = false;
}
describe('git-engine', () => {
let temp: { dir: string; dispose: () => Promise<void> };
beforeEach(async () => {
temp = await mkTempDir();
clearAllSessions();
});
afterEach(async () => {
clearAllSessions();
await temp.dispose();
});
describe('engineDetect', () => {
it("returns { mode: 'none' } for an .op file with no surrounding repo", async () => {
const opFile = await writeOpFile(temp.dir, 'orphan.op');
const result = await engineDetect(opFile);
expect(result.mode).toBe('none');
expect(sessionCount()).toBe(0);
});
it('registers a session and auto-binds the file when a single-file repo exists', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
// Use engineInit to create the repo first.
const initResult = await engineInit(opFile);
// Drop the init session so detect re-allocates a fresh one.
clearAllSessions();
const result = await engineDetect(opFile);
expect(result.mode).toBe('single-file');
if (result.mode === 'none') throw new Error('unreachable');
expect(result.trackedFilePath).toBe(resolve(opFile));
expect(result.engineKind).toBe('iso');
expect(result.gitdir).toBe(initResult.gitdir);
expect(sessionCount()).toBe(1);
});
it('detects a folder-mode repo when the .op file lives inside a parent .git', async () => {
const repoRoot = await mkSubdir(temp.dir, 'project');
const dotGit = await mkSubdir(repoRoot, '.git');
await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
const opFile = await writeOpFile(repoRoot, 'design.op');
const result = await engineDetect(opFile);
expect(result.mode).toBe('folder');
if (result.mode === 'none') throw new Error('unreachable');
expect(result.rootPath).toBe(resolve(repoRoot));
expect(result.trackedFilePath).toBe(resolve(opFile));
});
});
describe('engineInit', () => {
it('initializes a single-file repo, auto-binds, and returns a single candidate', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const result = await engineInit(opFile);
expect(result.mode).toBe('single-file');
expect(result.rootPath).toBe(temp.dir);
expect(result.trackedFilePath).toBe(resolve(opFile));
expect(result.candidates).toHaveLength(1);
expect(result.candidates[0].relativePath).toBe('login.op');
expect(result.candidates[0].milestoneCount).toBe(0);
expect(result.candidates[0].autosaveCount).toBe(0);
expect(sessionCount()).toBe(1);
});
});
describe('engineOpen + walk + bind', () => {
it('engineOpen on a folder-mode repo discovers all .op files and sets candidates', async () => {
const repoRoot = await mkSubdir(temp.dir, 'project');
const dotGit = await mkSubdir(repoRoot, '.git');
await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
await writeOpFile(repoRoot, 'a.op');
await writeOpFile(repoRoot, 'b.op');
await mkSubdir(repoRoot, 'subdir');
await writeOpFile(join(repoRoot, 'subdir'), 'c.op');
const result = await engineOpen(repoRoot);
expect(result.mode).toBe('folder');
expect(result.candidates).toHaveLength(3);
const rels = result.candidates.map((c) => c.relativePath).sort();
expect(rels).toEqual(['a.op', 'b.op', join('subdir', 'c.op')]);
});
it('engineOpen auto-binds when currentFilePath is inside the repo', async () => {
const repoRoot = await mkSubdir(temp.dir, 'project');
const dotGit = await mkSubdir(repoRoot, '.git');
await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
const opFile = await writeOpFile(repoRoot, 'design.op');
const result = await engineOpen(repoRoot, opFile);
expect(result.trackedFilePath).toBe(resolve(opFile));
});
it('engineOpen auto-binds the only candidate when currentFilePath is omitted', async () => {
const repoRoot = await mkSubdir(temp.dir, 'project');
const dotGit = await mkSubdir(repoRoot, '.git');
await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
const opFile = await writeOpFile(repoRoot, 'design.op');
const result = await engineOpen(repoRoot);
expect(result.trackedFilePath).toBe(resolve(opFile));
});
it('engineOpen leaves trackedFilePath null when multiple candidates exist and no currentFilePath', async () => {
const repoRoot = await mkSubdir(temp.dir, 'project');
const dotGit = await mkSubdir(repoRoot, '.git');
await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
await writeOpFile(repoRoot, 'a.op');
await writeOpFile(repoRoot, 'b.op');
const result = await engineOpen(repoRoot);
expect(result.trackedFilePath).toBeNull();
});
it('engineBindTrackedFile updates the session and rejects paths outside the repo', async () => {
const repoRoot = await mkSubdir(temp.dir, 'project');
const dotGit = await mkSubdir(repoRoot, '.git');
await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
await writeOpFile(repoRoot, 'a.op');
await writeOpFile(repoRoot, 'b.op');
const result = await engineOpen(repoRoot);
const a = join(repoRoot, 'a.op');
const bound = await engineBindTrackedFile(result.repoId, a);
expect(bound.trackedFilePath).toBe(resolve(a));
// Outside path is rejected.
await expect(
engineBindTrackedFile(result.repoId, join(temp.dir, 'outside.op')),
).rejects.toMatchObject({ name: 'GitError', code: 'open-failed' });
});
it('engineListCandidates re-walks the worktree and includes newly-added files', async () => {
const repoRoot = await mkSubdir(temp.dir, 'project');
const dotGit = await mkSubdir(repoRoot, '.git');
await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
await writeOpFile(repoRoot, 'a.op');
const result = await engineOpen(repoRoot);
expect(result.candidates).toHaveLength(1);
// Add a new file outside OpenPencil and refresh.
await writeOpFile(repoRoot, 'b.op');
const fresh = await engineListCandidates(result.repoId);
expect(fresh).toHaveLength(2);
});
it('engineClose removes the session and subsequent operations throw no-file', async () => {
const repoRoot = await mkSubdir(temp.dir, 'project');
const dotGit = await mkSubdir(repoRoot, '.git');
await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
await writeOpFile(repoRoot, 'a.op');
const result = await engineOpen(repoRoot);
engineClose(result.repoId);
await expect(engineListCandidates(result.repoId)).rejects.toMatchObject({
name: 'GitError',
code: 'no-file',
});
});
});
describe('engineStatus', () => {
it('reports branch=main and workingDirty=true on a fresh single-file repo', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const result = await engineInit(opFile);
const status = await engineStatus(result.repoId);
expect(status.trackedFilePath).toBe(resolve(opFile));
// currentBranch reads HEAD's symbolic ref which initSingleFile sets to
// refs/heads/main, so branch='main' even before any commit exists.
expect(status.branch).toBe('main');
expect(status.workingDirty).toBe(true);
expect(status.otherFilesDirty).toBe(0);
expect(status.mergeInProgress).toBe(false);
expect(status.ahead).toBe(0);
expect(status.behind).toBe(0);
});
it('after a milestone commit, workingDirty=false; after a disk edit, workingDirty=true', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
const session = (await import('../repo-session')).getSession(result.repoId)!;
const { commitFile, setRef } = await import('../git-iso');
const { hash } = await commitFile({
handle: session.handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
// Force the autosave ref to the same commit (this is what engineCommit
// milestone path will do in Task 8).
await setRef({
handle: session.handle,
ref: 'refs/openpencil/autosaves/main',
value: hash,
});
const cleanStatus = await engineStatus(result.repoId);
expect(cleanStatus.branch).toBe('main');
expect(cleanStatus.workingDirty).toBe(false);
// Mutate the file on disk.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r2' }],
});
const dirtyStatus = await engineStatus(result.repoId);
expect(dirtyStatus.workingDirty).toBe(true);
});
it('folder mode: a modified tracked .gitignore counts toward otherFilesDirty', async () => {
// Build a folder-mode repo with two tracked files: design.op and .gitignore.
const repoRoot = temp.dir; // use temp.dir directly so the gitdir is .git here
const opFile = await writeOpFile(repoRoot, 'design.op');
const gitignorePath = join(repoRoot, '.gitignore');
await fsp.writeFile(gitignorePath, 'node_modules\n', 'utf-8');
// Init a folder-mode repo via isomorphic-git directly (engineInit only
// does single-file mode).
const isoGit = await import('isomorphic-git');
const fsMod = await import('node:fs');
await isoGit.init({
fs: fsMod,
dir: repoRoot,
defaultBranch: 'main',
});
// Stage and commit BOTH files in one commit so both are in the heads tree.
await isoGit.add({ fs: fsMod, dir: repoRoot, filepath: 'design.op' });
await isoGit.add({ fs: fsMod, dir: repoRoot, filepath: '.gitignore' });
await isoGit.commit({
fs: fsMod,
dir: repoRoot,
message: 'initial',
author: { name: 't', email: 't@example.com' },
});
// Open via the engine and bind design.op as the tracked file.
const result = await engineOpen(repoRoot, opFile);
expect(result.trackedFilePath).toBe(resolve(opFile));
// Clean state: both files match the tree → otherFilesDirty=0.
const cleanStatus = await engineStatus(result.repoId);
expect(cleanStatus.otherFilesDirty).toBe(0);
expect(cleanStatus.otherFilesPaths).toEqual([]);
// Modify .gitignore on disk and re-check.
await fsp.writeFile(gitignorePath, 'node_modules\n.DS_Store\n', 'utf-8');
const dirtyStatus = await engineStatus(result.repoId);
expect(dirtyStatus.otherFilesDirty).toBe(1);
expect(dirtyStatus.otherFilesPaths).toEqual(['.gitignore']);
});
it('folder mode: a tracked file deleted from disk counts toward otherFilesDirty', async () => {
const repoRoot = temp.dir;
const opFile = await writeOpFile(repoRoot, 'design.op');
const notesPath = join(repoRoot, 'notes.md');
await fsp.writeFile(notesPath, '# notes\n', 'utf-8');
const isoGit = await import('isomorphic-git');
const fsMod = await import('node:fs');
await isoGit.init({ fs: fsMod, dir: repoRoot, defaultBranch: 'main' });
await isoGit.add({ fs: fsMod, dir: repoRoot, filepath: 'design.op' });
await isoGit.add({ fs: fsMod, dir: repoRoot, filepath: 'notes.md' });
await isoGit.commit({
fs: fsMod,
dir: repoRoot,
message: 'initial',
author: { name: 't', email: 't@example.com' },
});
const result = await engineOpen(repoRoot, opFile);
// Delete notes.md from disk; the heads tree still has it.
await fsp.unlink(notesPath);
const status = await engineStatus(result.repoId);
expect(status.otherFilesDirty).toBe(1);
expect(status.otherFilesPaths).toEqual(['notes.md']);
});
});
describe('engineLog', () => {
it("returns milestone-kind entries when querying ref: 'main'", async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
const session = (await import('../repo-session')).getSession(result.repoId)!;
const { commitFile, setRef } = await import('../git-iso');
const { hash: h1 } = await commitFile({
handle: session.handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await setRef({
handle: session.handle,
ref: 'refs/openpencil/autosaves/main',
value: h1,
});
const log = await engineLog(result.repoId, { ref: 'main', limit: 10 });
expect(log).toHaveLength(1);
expect(log[0].kind).toBe('milestone');
expect(log[0].hash).toBe(h1);
});
it("autosave commits are kind='autosave' when queried via ref: 'autosaves'", async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
const session = (await import('../repo-session')).getSession(result.repoId)!;
const { commitFile, setRef } = await import('../git-iso');
// Milestone first.
const { hash: m1 } = await commitFile({
handle: session.handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'milestone',
author: { name: 't', email: 't@example.com' },
});
await setRef({
handle: session.handle,
ref: 'refs/openpencil/autosaves/main',
value: m1,
});
// Then an autosave on top.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r2' }],
});
const { hash: a1 } = await commitFile({
handle: session.handle,
filepath: 'login.op',
ref: 'refs/openpencil/autosaves/main',
message: 'auto',
author: { name: 't', email: 't@example.com' },
});
const log = await engineLog(result.repoId, { ref: 'autosaves', limit: 10 });
// log[0] = a1 (autosave), log[1] = m1 (milestone, reachable as parent)
expect(log[0].hash).toBe(a1);
expect(log[0].kind).toBe('autosave');
expect(log[1].hash).toBe(m1);
expect(log[1].kind).toBe('milestone');
});
it('respects the limit parameter', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
const session = (await import('../repo-session')).getSession(result.repoId)!;
const { commitFile, setRef } = await import('../git-iso');
// Make 3 milestones.
for (let i = 0; i < 3; i++) {
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: `r${i}` }],
});
const { hash } = await commitFile({
handle: session.handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: `m${i}`,
author: { name: 't', email: 't@example.com' },
});
await setRef({
handle: session.handle,
ref: 'refs/openpencil/autosaves/main',
value: hash,
});
}
const log = await engineLog(result.repoId, { ref: 'main', limit: 2 });
expect(log).toHaveLength(2);
});
});
describe('engineCommit', () => {
it('milestone commit advances both heads and autosaves to the same hash', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
const { hash } = await engineCommit(result.repoId, {
kind: 'milestone',
message: 'first milestone',
author: { name: 't', email: 't@example.com' },
});
const session = (await import('../repo-session')).getSession(result.repoId)!;
const isoGit = await import('isomorphic-git');
const fsMod = await import('node:fs');
const headsTip = await isoGit.resolveRef({
fs: fsMod,
gitdir: session.handle.gitdir,
ref: 'refs/heads/main',
});
const autoTip = await isoGit.resolveRef({
fs: fsMod,
gitdir: session.handle.gitdir,
ref: 'refs/openpencil/autosaves/main',
});
expect(headsTip).toBe(hash);
expect(autoTip).toBe(hash);
});
it('autosave commit advances only the autosaves ref, leaving heads behind', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
// First a milestone.
const { hash: m1 } = await engineCommit(result.repoId, {
kind: 'milestone',
message: 'milestone',
author: { name: 't', email: 't@example.com' },
});
// Mutate and autosave.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r2' }],
});
const { hash: a1 } = await engineCommit(result.repoId, {
kind: 'autosave',
message: 'auto',
author: { name: 't', email: 't@example.com' },
});
expect(a1).not.toBe(m1);
const session = (await import('../repo-session')).getSession(result.repoId)!;
const isoGit = await import('isomorphic-git');
const fsMod = await import('node:fs');
const headsTip = await isoGit.resolveRef({
fs: fsMod,
gitdir: session.handle.gitdir,
ref: 'refs/heads/main',
});
const autoTip = await isoGit.resolveRef({
fs: fsMod,
gitdir: session.handle.gitdir,
ref: 'refs/openpencil/autosaves/main',
});
expect(headsTip).toBe(m1); // unchanged
expect(autoTip).toBe(a1); // advanced
});
it('a second milestone after autosaves abandons the autosave chain', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
// milestone, autosave, autosave
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'm1',
author: { name: 't', email: 't@example.com' },
});
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
await engineCommit(result.repoId, {
kind: 'autosave',
message: 'a1',
author: { name: 't', email: 't@example.com' },
});
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r3' }] });
await engineCommit(result.repoId, {
kind: 'autosave',
message: 'a2',
author: { name: 't', email: 't@example.com' },
});
// Now a second milestone.
const { hash: m2 } = await engineCommit(result.repoId, {
kind: 'milestone',
message: 'm2',
author: { name: 't', email: 't@example.com' },
});
// The heads ref should now show 2 milestones (m2 → m1).
const headsLog = await engineLog(result.repoId, { ref: 'main', limit: 10 });
expect(headsLog).toHaveLength(2);
expect(headsLog[0].hash).toBe(m2);
expect(headsLog[0].kind).toBe('milestone');
expect(headsLog[1].kind).toBe('milestone');
// The autosaves ref now points at m2 (force-updated), so its log walks
// m2 → m1. The intermediate autosaves a1, a2 are unreachable.
const autoLog = await engineLog(result.repoId, { ref: 'autosaves', limit: 10 });
expect(autoLog).toHaveLength(2);
expect(autoLog[0].hash).toBe(m2);
});
it("throws 'commit-empty' when committing the same content twice", async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const result = await engineInit(opFile);
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await expect(
engineCommit(result.repoId, {
kind: 'milestone',
message: 'second',
author: { name: 't', email: 't@example.com' },
}),
).rejects.toMatchObject({ name: 'GitError', code: 'commit-empty' });
});
it('engineCommit autosave is a no-op when disk content matches tip blob', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
// Create an initial milestone so there is a headsRef parent.
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'initial',
author: { name: 't', email: 't@example.com' },
});
// Mutate the file and autosave — creates the autosave tip.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r2' }],
});
const { hash: first } = await engineCommit(result.repoId, {
kind: 'autosave',
message: 'auto-1',
author: { name: 't', email: 't@example.com' },
});
// Second autosave with identical disk content — must be a no-op.
const { hash: second } = await engineCommit(result.repoId, {
kind: 'autosave',
message: 'auto-2',
author: { name: 't', email: 't@example.com' },
});
expect(second).toBe(first);
});
it('engineCommit autosave creates a new commit when disk content changed', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
// Initial milestone.
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'initial',
author: { name: 't', email: 't@example.com' },
});
// First autosave with mutated content.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r2' }],
});
const { hash: first } = await engineCommit(result.repoId, {
kind: 'autosave',
message: 'auto-1',
author: { name: 't', email: 't@example.com' },
});
// Mutate again, then autosave — content differs, so a new commit is expected.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r3' }],
});
const { hash: second } = await engineCommit(result.repoId, {
kind: 'autosave',
message: 'auto-2',
author: { name: 't', email: 't@example.com' },
});
expect(second).not.toBe(first);
});
});
describe('engineRestore + enginePromote', () => {
it('engineRestore writes a previous commit to disk without recording a new commit', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
const { hash: m1 } = await engineCommit(result.repoId, {
kind: 'milestone',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
// Mutate.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r2' }],
});
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'second',
author: { name: 't', email: 't@example.com' },
});
const logBefore = await engineLog(result.repoId, { ref: 'main', limit: 10 });
// Restore to first milestone.
await engineRestore(result.repoId, m1);
const restored = await fsp.readFile(opFile, 'utf-8');
expect(JSON.parse(restored).children[0].id).toBe('r1');
// History unchanged (restore doesn't commit).
const logAfter = await engineLog(result.repoId, { ref: 'main', limit: 10 });
expect(logAfter).toHaveLength(logBefore.length);
});
it('enginePromote reads an autosave, writes it, and records a milestone', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
// Initial milestone.
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'm1',
author: { name: 't', email: 't@example.com' },
});
// Autosave with new content.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r2' }],
});
const { hash: a1 } = await engineCommit(result.repoId, {
kind: 'autosave',
message: 'auto',
author: { name: 't', email: 't@example.com' },
});
// The user keeps editing and ends up at r3 in memory, but on disk is
// currently r2 (last autosave). Promote a1 to a milestone.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r3' }],
});
const { hash: m2 } = await enginePromote(result.repoId, a1, 'promoted to milestone', {
name: 't',
email: 't@example.com',
});
expect(m2).not.toBe(a1);
// Working tree should now hold r2 (the promoted autosave content), not r3.
const onDisk = await fsp.readFile(opFile, 'utf-8');
expect(JSON.parse(onDisk).children[0].id).toBe('r2');
// Heads ref now has 2 milestones (m1, m2).
const headsLog = await engineLog(result.repoId, { ref: 'main', limit: 10 });
expect(headsLog).toHaveLength(2);
expect(headsLog[0].hash).toBe(m2);
expect(headsLog[0].message.trim()).toBe('promoted to milestone');
});
it('engineRestore throws no-file when session has no trackedFilePath', async () => {
const repoRoot = await mkSubdir(temp.dir, 'project');
const dotGit = await mkSubdir(repoRoot, '.git');
await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
await writeOpFile(repoRoot, 'a.op');
await writeOpFile(repoRoot, 'b.op');
const result = await engineOpen(repoRoot); // multiple candidates → no auto-bind
await expect(engineRestore(result.repoId, 'deadbeef')).rejects.toMatchObject({
name: 'GitError',
code: 'no-file',
});
});
});
describe('branch operations', () => {
it('engineBranchList returns the current branch decorated with lastCommit', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
const list = await engineBranchList(result.repoId);
expect(list).toHaveLength(1);
expect(list[0].name).toBe('main');
expect(list[0].isCurrent).toBe(true);
expect(list[0].lastCommit?.message).toBe('first');
});
it('engineBranchCreate adds a new branch from current HEAD', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await engineBranchCreate(result.repoId, { name: 'feature-x' });
const list = await engineBranchList(result.repoId);
const names = list.map((b) => b.name).sort();
expect(names).toEqual(['feature-x', 'main']);
});
it('engineBranchSwitch updates the working tree to the target branch tip', async () => {
// Mirrors Phase 1b's switchBranch test structure: commit r1 on main,
// branch off, commit r2 on feature-x (working tree currently r2), then
// switch to main and verify disk reverts to r1. This is the cleanest
// observable test because filepaths-scoped checkout reads the target
// ref's tree and writes it to disk.
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await engineBranchCreate(result.repoId, { name: 'feature-x' });
// Mutate the working tree, then commit r2 onto feature-x via the
// underlying primitive (the engine commits to current HEAD only).
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r2' }],
});
const session = (await import('../repo-session')).getSession(result.repoId)!;
const { commitFile } = await import('../git-iso');
await commitFile({
handle: session.handle,
filepath: 'login.op',
ref: 'refs/heads/feature-x',
message: 'feature change',
author: { name: 't', email: 't@example.com' },
});
// Working tree currently has r2 (from the writeOpFile above).
// Switch to main and verify the file reverts to r1.
await engineBranchSwitch(result.repoId, 'main');
const onDisk = await fsp.readFile(opFile, 'utf-8');
expect(JSON.parse(onDisk).children[0].id).toBe('r1');
});
it('engineBranchDelete removes a non-current branch', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await engineBranchCreate(result.repoId, { name: 'feature-x' });
await engineBranchDelete(result.repoId, 'feature-x');
const list = await engineBranchList(result.repoId);
expect(list.map((b) => b.name)).toEqual(['main']);
});
it('engineBranchDelete refuses to delete the current branch', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await expect(engineBranchDelete(result.repoId, 'main')).rejects.toMatchObject({
name: 'GitError',
code: 'branch-current',
});
});
it('engineBranchDelete refuses an unmerged branch without force and succeeds with force=true', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const result = await engineInit(opFile);
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'base',
author: { name: 't', email: 't@example.com' },
});
await engineBranchCreate(result.repoId, { name: 'feature-x' });
// Switch to the new branch, modify the tracked file, and commit so
// feature-x has a commit main does not see.
await engineBranchSwitch(result.repoId, 'feature-x');
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r2' }],
});
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'feature only',
author: { name: 't', email: 't@example.com' },
});
await engineBranchSwitch(result.repoId, 'main');
await expect(engineBranchDelete(result.repoId, 'feature-x')).rejects.toMatchObject({
name: 'GitError',
code: 'branch-unmerged',
});
await expect(
engineBranchDelete(result.repoId, 'feature-x', { force: true }),
).resolves.toBeUndefined();
const list = await engineBranchList(result.repoId);
expect(list.map((b) => b.name)).toEqual(['main']);
});
});
describe('shouldUseSys dispatch', () => {
it('routes git@ and ssh:// URLs to sys', async () => {
const { shouldUseSys } = await import('../git-engine');
expect(shouldUseSys('git@github.com:user/repo.git', undefined)).toBe(true);
expect(shouldUseSys('ssh://git@github.com/user/repo.git', undefined)).toBe(true);
});
it('routes https URLs to iso', async () => {
const { shouldUseSys } = await import('../git-engine');
expect(shouldUseSys('https://github.com/user/repo.git', undefined)).toBe(false);
});
it('routes auth.kind=ssh to sys regardless of URL', async () => {
const { shouldUseSys } = await import('../git-engine');
expect(shouldUseSys('https://github.com/user/repo.git', { kind: 'ssh', keyId: 'k1' })).toBe(
true,
);
});
});
describe('parseHost', () => {
it('extracts the host from https/http/ssh URLs', async () => {
const { parseHost } = await import('../git-engine');
expect(parseHost('https://github.com/user/repo.git')).toBe('github.com');
expect(parseHost('http://gitlab.example.com:8080/u/r')).toBe('gitlab.example.com');
expect(parseHost('ssh://git@github.com:22/user/repo.git')).toBe('github.com');
});
it('extracts the host from SCP-style git URLs', async () => {
const { parseHost } = await import('../git-engine');
expect(parseHost('git@github.com:user/repo.git')).toBe('github.com');
expect(parseHost('user@gitlab.com:org/project.git')).toBe('gitlab.com');
});
it('returns null for unparseable inputs', async () => {
const { parseHost } = await import('../git-engine');
expect(parseHost('/local/path/to/bare.git')).toBeNull();
expect(parseHost('not a url at all')).toBeNull();
});
});
describe('resolveAuthForRemote', () => {
it('returns the explicit auth argument unchanged when provided', async () => {
const { resolveAuthForRemote, setAuthStore } = await import('../git-engine');
// No store needed; explicit auth wins.
setAuthStore(null);
const explicit = { kind: 'token' as const, username: 'a', token: 'b' };
const got = await resolveAuthForRemote('https://github.com/u/r.git', explicit);
expect(got).toBe(explicit);
});
it('falls back to the stored credential when explicit auth is omitted', async () => {
const { resolveAuthForRemote, setAuthStore } = await import('../git-engine');
const { createAuthStore, createInMemoryBackend } = await import('../auth-store');
const filePath = join(temp.dir, 'auth.bin');
const store = createAuthStore({ filePath, backend: createInMemoryBackend() });
await store.set('github.com', { kind: 'token', username: 'kay', token: 'stored-pat' });
setAuthStore(store);
const got = await resolveAuthForRemote('https://github.com/u/r.git', undefined);
expect(got).toEqual({ kind: 'token', username: 'kay', token: 'stored-pat' });
// Cleanup so the singleton doesn't leak into other tests.
setAuthStore(null);
});
it('returns undefined when no explicit auth, no store, and no host match', async () => {
const { resolveAuthForRemote, setAuthStore } = await import('../git-engine');
setAuthStore(null);
expect(await resolveAuthForRemote('https://unknown.com/u/r.git', undefined)).toBeUndefined();
expect(await resolveAuthForRemote(null, undefined)).toBeUndefined();
});
});
describe('engineFetch + enginePull (system git gated)', () => {
async function setupClonePair() {
const remoteDir = join(temp.dir, 'remote.git');
const aDir = join(temp.dir, 'a');
const bDir = join(temp.dir, 'b');
await execFileAsync('git', ['init', '--bare', '-b', 'main', remoteDir]);
await execFileAsync('git', ['clone', remoteDir, aDir]);
await execFileAsync('git', ['-C', aDir, 'checkout', '-b', 'main']);
await fsp.writeFile(join(aDir, 'design.op'), '{"version":"1.0.0","children":[{"id":"r1"}]}');
await execFileAsync('git', ['-C', aDir, 'add', '.']);
await execFileAsync(
'git',
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'one'],
{},
);
await execFileAsync('git', ['-C', aDir, 'push', '-u', 'origin', 'main']);
await execFileAsync('git', ['clone', remoteDir, bDir]);
return { remoteDir, aDir, bDir };
}
it.skipIf(!systemGitAvailable)(
'engineFetch reports behind=1 after upstream advances',
async () => {
const { aDir, bDir } = await setupClonePair();
// a commits and pushes a new commit
await fsp.writeFile(
join(aDir, 'design.op'),
'{"version":"1.0.0","children":[{"id":"r2"}]}',
);
await execFileAsync('git', ['-C', aDir, 'add', '.']);
await execFileAsync(
'git',
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'two'],
{},
);
await execFileAsync('git', ['-C', aDir, 'push']);
// Open b via the engine and fetch.
const result = await engineOpen(bDir, join(bDir, 'design.op'));
const fetchResult = await engineFetch(result.repoId);
expect(fetchResult).toEqual({ ahead: 0, behind: 1 });
},
);
it.skipIf(!systemGitAvailable)('enginePull fast-forwards a clean clone', async () => {
const { aDir, bDir } = await setupClonePair();
// a pushes a new commit
await fsp.writeFile(join(aDir, 'design.op'), '{"version":"1.0.0","children":[{"id":"r2"}]}');
await execFileAsync('git', ['-C', aDir, 'add', '.']);
await execFileAsync(
'git',
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'two'],
{},
);
await execFileAsync('git', ['-C', aDir, 'push']);
const result = await engineOpen(bDir, join(bDir, 'design.op'));
const pullResult = await enginePull(result.repoId);
expect(pullResult.result).toBe('fast-forward');
// Verify b's design.op now has r2.
const content = await fsp.readFile(join(bDir, 'design.op'), 'utf-8');
expect(JSON.parse(content).children[0].id).toBe('r2');
});
it.skipIf(!systemGitAvailable)(
'enginePull in folder mode enters merge workflow when histories diverge (Phase 7a)',
async () => {
const { aDir, bDir } = await setupClonePair();
// Both a and b commit divergently with conflicting fill values on node 'r1'.
await fsp.writeFile(
join(aDir, 'design.op'),
JSON.stringify({
version: '1.0.0',
children: [
{
id: 'r1',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#0000ff' }],
},
],
}),
);
await execFileAsync('git', ['-C', aDir, 'add', '.']);
await execFileAsync(
'git',
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'a: blue'],
{},
);
await execFileAsync('git', ['-C', aDir, 'push']);
await fsp.writeFile(
join(bDir, 'design.op'),
JSON.stringify({
version: '1.0.0',
children: [
{
id: 'r1',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#00ff00' }],
},
],
}),
);
await execFileAsync('git', ['-C', bDir, 'add', '.']);
await execFileAsync(
'git',
['-C', bDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'b: green'],
{},
);
const result = await engineOpen(bDir, join(bDir, 'design.op'));
// Phase 7a: folder-mode divergent pull should now enter the merge workflow,
// returning 'conflict' (or 'merge' if pen-core finds no semantic conflicts).
const pullResult = await enginePull(result.repoId);
expect(['conflict', 'merge', 'fast-forward']).toContain(pullResult.result);
// design.op on disk must be readable JSON (not conflict markers).
const onDisk = await fsp.readFile(join(bDir, 'design.op'), 'utf-8');
expect(() => JSON.parse(onDisk)).not.toThrow();
},
);
});
describe('enginePush (system git gated)', () => {
it.skipIf(!systemGitAvailable)(
'enginePush succeeds on a clean local clone with new commits',
async () => {
const remoteDir = join(temp.dir, 'remote.git');
const cloneDir = join(temp.dir, 'clone');
await execFileAsync('git', ['init', '--bare', '-b', 'main', remoteDir]);
await execFileAsync('git', ['clone', remoteDir, cloneDir]);
await execFileAsync('git', ['-C', cloneDir, 'checkout', '-b', 'main']);
await fsp.writeFile(
join(cloneDir, 'design.op'),
'{"version":"1.0.0","children":[{"id":"r1"}]}',
);
await execFileAsync('git', ['-C', cloneDir, 'add', '.']);
await execFileAsync(
'git',
[
'-C',
cloneDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'first',
],
{},
);
// First push needs --set-upstream; we use the underlying execFile for it,
// then enginePush handles subsequent pushes.
await execFileAsync('git', ['-C', cloneDir, 'push', '-u', 'origin', 'main']);
// Make another commit, then push via enginePush.
await fsp.writeFile(
join(cloneDir, 'design.op'),
'{"version":"1.0.0","children":[{"id":"r2"}]}',
);
await execFileAsync('git', ['-C', cloneDir, 'add', '.']);
await execFileAsync(
'git',
[
'-C',
cloneDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'second',
],
{},
);
const result = await engineOpen(cloneDir, join(cloneDir, 'design.op'));
const pushResult = await enginePush(result.repoId);
expect(pushResult.result).toBe('ok');
// Verify the bare remote saw the second commit.
const { stdout } = await execFileAsync('git', [
'-C',
remoteDir,
'log',
'--oneline',
'main',
]);
expect(stdout).toContain('second');
},
);
});
describe('engineDiff', () => {
it('returns 0/0/0/0 summary for two identical commits', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }],
});
const result = await engineInit(opFile);
const { hash: h1 } = await engineCommit(result.repoId, {
kind: 'milestone',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
const diff = await engineDiff(result.repoId, h1, h1);
expect(diff.summary).toEqual({
framesChanged: 0,
nodesAdded: 0,
nodesRemoved: 0,
nodesModified: 0,
});
expect(diff.patches).toEqual([]);
});
it('reports nodes added/removed between two commits', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }],
});
const result = await engineInit(opFile);
const { hash: h1 } = await engineCommit(result.repoId, {
kind: 'milestone',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
// Add a node and commit again.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 },
{ id: 'b', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 },
],
});
const { hash: h2 } = await engineCommit(result.repoId, {
kind: 'milestone',
message: 'second',
author: { name: 't', email: 't@example.com' },
});
const diff = await engineDiff(result.repoId, h1, h2);
expect(diff.summary.nodesAdded).toBe(1);
expect(diff.summary.nodesRemoved).toBe(0);
expect(diff.patches.some((p) => p.op === 'add' && p.nodeId === 'b')).toBe(true);
});
});
describe('engineBranchMerge', () => {
it('clean merge of disjoint changes produces a merge commit', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }],
});
const result = await engineInit(opFile);
// Base commit on main.
const { hash: baseHash } = await engineCommit(result.repoId, {
kind: 'milestone',
message: 'base',
author: { name: 't', email: 't@example.com' },
});
// Branch off, commit different content on feature.
await engineBranchCreate(result.repoId, { name: 'feature' });
const session = (await import('../repo-session')).getSession(result.repoId)!;
const { commitFile: cf, setRef: sr } = await import('../git-iso');
// Theirs adds node 'b'.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 },
{ id: 'b', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 },
],
});
const { hash: theirs } = await cf({
handle: session.handle,
filepath: 'login.op',
ref: 'refs/heads/feature',
message: 'add b',
author: { name: 't', email: 't@example.com' },
parents: [baseHash],
});
await sr({
handle: session.handle,
ref: 'refs/openpencil/autosaves/feature',
value: theirs,
});
// Restore main's content.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }],
});
// Ours adds node 'c'.
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 },
{ id: 'c', type: 'rectangle', x: 9, y: 9, width: 1, height: 1 },
],
});
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'add c',
author: { name: 't', email: 't@example.com' },
});
// Now merge feature into main.
const mergeResult = await engineBranchMerge(result.repoId, 'feature');
expect(mergeResult.result).toBe('merge');
expect(mergeResult.conflicts).toBeUndefined();
// Working tree should now contain both b and c.
const onDisk = JSON.parse(await fsp.readFile(opFile, 'utf-8'));
const ids = (onDisk.children as Array<{ id: string }>).map((n) => n.id).sort();
expect(ids).toEqual(['a', 'b', 'c']);
});
it('fast-forward when ours is an ancestor of theirs', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }],
});
const result = await engineInit(opFile);
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'base',
author: { name: 't', email: 't@example.com' },
});
await engineBranchCreate(result.repoId, { name: 'feature' });
// Switch to feature and add a commit.
await engineBranchSwitch(result.repoId, 'feature');
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 },
{ id: 'b', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 },
],
});
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'add b on feature',
author: { name: 't', email: 't@example.com' },
});
// Switch back to main and merge feature.
await engineBranchSwitch(result.repoId, 'main');
const mergeResult = await engineBranchMerge(result.repoId, 'feature');
expect(mergeResult.result).toBe('fast-forward');
});
it('conflict path stashes InflightMerge and returns the bag', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{
id: 'a',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#ff0000' }],
},
],
});
const result = await engineInit(opFile);
const { hash: baseHash } = await engineCommit(result.repoId, {
kind: 'milestone',
message: 'base',
author: { name: 't', email: 't@example.com' },
});
// feature: change to blue
await engineBranchCreate(result.repoId, { name: 'feature' });
const session = (await import('../repo-session')).getSession(result.repoId)!;
const { commitFile: cf, setRef: sr } = await import('../git-iso');
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{
id: 'a',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#0000ff' }],
},
],
});
const { hash: theirs } = await cf({
handle: session.handle,
filepath: 'login.op',
ref: 'refs/heads/feature',
message: 'blue',
author: { name: 't', email: 't@example.com' },
parents: [baseHash],
});
await sr({
handle: session.handle,
ref: 'refs/openpencil/autosaves/feature',
value: theirs,
});
// main: change to green (after restoring main's content first)
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{
id: 'a',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#ff0000' }],
},
],
});
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{
id: 'a',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#00ff00' }],
},
],
});
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'green',
author: { name: 't', email: 't@example.com' },
});
const mergeResult = await engineBranchMerge(result.repoId, 'feature');
expect(mergeResult.result).toBe('conflict');
expect(mergeResult.conflicts).toBeDefined();
expect(mergeResult.conflicts!.nodeConflicts.length).toBeGreaterThan(0);
// The session should now hold the InflightMerge.
const refreshed = (await import('../repo-session')).getSession(result.repoId)!;
expect(refreshed.inflightMerge).not.toBeNull();
expect(refreshed.inflightMerge!.conflictMap.size).toBeGreaterThan(0);
});
});
describe('engineResolveConflict + engineApplyMerge + engineAbortMerge', () => {
// Helper: build a repo with a known conflict in flight.
async function setupConflict() {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{
id: 'a',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#ff0000' }],
},
],
});
const result = await engineInit(opFile);
const { hash: baseHash } = await engineCommit(result.repoId, {
kind: 'milestone',
message: 'base',
author: { name: 't', email: 't@example.com' },
});
await engineBranchCreate(result.repoId, { name: 'feature' });
const session = (await import('../repo-session')).getSession(result.repoId)!;
const { commitFile: cf, setRef: sr } = await import('../git-iso');
// theirs: blue
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{
id: 'a',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#0000ff' }],
},
],
});
const { hash: theirsHash } = await cf({
handle: session.handle,
filepath: 'login.op',
ref: 'refs/heads/feature',
message: 'blue',
author: { name: 't', email: 't@example.com' },
parents: [baseHash],
});
await sr({
handle: session.handle,
ref: 'refs/openpencil/autosaves/feature',
value: theirsHash,
});
// ours: green
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{
id: 'a',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#ff0000' }],
},
],
});
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{
id: 'a',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#00ff00' }],
},
],
});
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'green',
author: { name: 't', email: 't@example.com' },
});
const mergeResult = await engineBranchMerge(result.repoId, 'feature');
if (mergeResult.result !== 'conflict') {
throw new Error('expected conflict result');
}
return { result, opFile, conflictId: mergeResult.conflicts!.nodeConflicts[0].id };
}
it('engineResolveConflict records the choice in session state', async () => {
const { result, conflictId } = await setupConflict();
await engineResolveConflict(result.repoId, conflictId, { kind: 'theirs' });
const session = (await import('../repo-session')).getSession(result.repoId)!;
expect(session.inflightMerge!.resolutions.get(conflictId)).toEqual({ kind: 'theirs' });
});
it('engineResolveConflict throws on unknown conflict id', async () => {
const { result } = await setupConflict();
await expect(
engineResolveConflict(result.repoId, 'node:_:nope', { kind: 'ours' }),
).rejects.toMatchObject({ name: 'GitError', code: 'engine-crash' });
});
it('engineApplyMerge throws merge-still-conflicted when resolutions are missing', async () => {
const { result } = await setupConflict();
await expect(engineApplyMerge(result.repoId)).rejects.toMatchObject({
name: 'GitError',
code: 'merge-still-conflicted',
});
});
it('engineApplyMerge writes the merged doc and creates the merge commit', async () => {
const { result, opFile, conflictId } = await setupConflict();
await engineResolveConflict(result.repoId, conflictId, { kind: 'theirs' });
const applied = await engineApplyMerge(result.repoId);
expect(applied.noop).toBe(false);
expect(applied.hash).toMatch(/^[a-f0-9]{40}$/);
// The working tree should now have theirs's blue rectangle.
const onDisk = JSON.parse(await fsp.readFile(opFile, 'utf-8'));
expect(onDisk.children[0].fill[0].color).toBe('#0000ff');
// Session inflightMerge should be cleared.
const session = (await import('../repo-session')).getSession(result.repoId)!;
expect(session.inflightMerge).toBeNull();
// The merge commit should have two parents.
const isoGit = await import('isomorphic-git');
const fsMod = await import('node:fs');
const commit = await isoGit.readCommit({
fs: fsMod,
gitdir: session.handle.gitdir,
oid: applied.hash,
});
expect(commit.commit.parent.length).toBe(2);
});
it('engineApplyMerge with no in-flight merge returns noop=true', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [],
});
const result = await engineInit(opFile);
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
const applied = await engineApplyMerge(result.repoId);
expect(applied.noop).toBe(true);
});
it('engineAbortMerge clears the in-flight merge state', async () => {
const { result } = await setupConflict();
await engineAbortMerge(result.repoId);
const session = (await import('../repo-session')).getSession(result.repoId)!;
expect(session.inflightMerge).toBeNull();
});
});
describe('engineStatus with in-flight merge', () => {
it('mergeInProgress=false on a fresh repo', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [],
});
const result = await engineInit(opFile);
const status = await engineStatus(result.repoId);
expect(status.mergeInProgress).toBe(false);
expect(status.unresolvedFiles).toEqual([]);
expect(status.conflicts).toBeNull();
});
it('mergeInProgress=true and conflicts populated when a merge is in flight', async () => {
// Build a conflict using the same pattern as the Task 8 setupConflict.
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{
id: 'a',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#ff0000' }],
},
],
});
const result = await engineInit(opFile);
const { hash: baseHash } = await engineCommit(result.repoId, {
kind: 'milestone',
message: 'base',
author: { name: 't', email: 't@example.com' },
});
await engineBranchCreate(result.repoId, { name: 'feature' });
const session = (await import('../repo-session')).getSession(result.repoId)!;
const { commitFile: cf, setRef: sr } = await import('../git-iso');
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{
id: 'a',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#0000ff' }],
},
],
});
const { hash: theirs } = await cf({
handle: session.handle,
filepath: 'login.op',
ref: 'refs/heads/feature',
message: 'blue',
author: { name: 't', email: 't@example.com' },
parents: [baseHash],
});
await sr({
handle: session.handle,
ref: 'refs/openpencil/autosaves/feature',
value: theirs,
});
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
name: 'd',
children: [
{
id: 'a',
type: 'rectangle',
x: 0,
y: 0,
width: 1,
height: 1,
fill: [{ type: 'solid', color: '#00ff00' }],
},
],
});
await engineCommit(result.repoId, {
kind: 'milestone',
message: 'green',
author: { name: 't', email: 't@example.com' },
});
const mergeResult = await engineBranchMerge(result.repoId, 'feature');
expect(mergeResult.result).toBe('conflict');
const status = await engineStatus(result.repoId);
expect(status.mergeInProgress).toBe(true);
expect(status.conflicts).not.toBeNull();
expect(status.conflicts!.nodeConflicts.length).toBeGreaterThan(0);
expect(status.unresolvedFiles).toEqual(['login.op']);
});
});
describe('engineRemoteGet + engineRemoteSet (Phase 6a)', () => {
it('returns { url: null, host: null } when origin is absent', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const init = await engineInit(opFile);
const info = await engineRemoteGet(init.repoId);
expect(info).toEqual({ name: 'origin', url: null, host: null });
});
it('engineRemoteSet adds origin when it does not exist and parses the host', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const init = await engineInit(opFile);
const result = await engineRemoteSet(init.repoId, 'https://github.com/foo/bar.git');
expect(result).toEqual({
name: 'origin',
url: 'https://github.com/foo/bar.git',
host: 'github.com',
});
// engineRemoteGet now returns the same thing.
const got = await engineRemoteGet(init.repoId);
expect(got).toEqual(result);
});
it('engineRemoteSet updates an existing origin in place', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const init = await engineInit(opFile);
await engineRemoteSet(init.repoId, 'https://github.com/foo/bar.git');
const updated = await engineRemoteSet(init.repoId, 'https://gitlab.com/foo/bar.git');
expect(updated).toEqual({
name: 'origin',
url: 'https://gitlab.com/foo/bar.git',
host: 'gitlab.com',
});
// Read back to confirm there is exactly one origin and it is the new url.
const got = await engineRemoteGet(init.repoId);
expect(got.url).toBe('https://gitlab.com/foo/bar.git');
});
it('engineRemoteSet(null) removes origin and is idempotent', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const init = await engineInit(opFile);
// Add then remove.
await engineRemoteSet(init.repoId, 'https://github.com/foo/bar.git');
const removed = await engineRemoteSet(init.repoId, null);
expect(removed).toEqual({ name: 'origin', url: null, host: null });
// Removing again must not throw. Note: writeRemoteOrigin() does NOT
// wrap `git.deleteRemote` in a try/catch — this test proves the call
// is naturally idempotent because isomorphic-git implements it as a
// filter over parsed config entries that tolerates an absent section.
// Wrapping the call would silently swallow real I/O errors from
// GitConfigManager.save (EACCES, ENOSPC, etc.), so the lack of a
// catch is load-bearing.
const removedAgain = await engineRemoteSet(init.repoId, null);
expect(removedAgain).toEqual({ name: 'origin', url: null, host: null });
// engineRemoteGet confirms origin is gone.
const got = await engineRemoteGet(init.repoId);
expect(got.url).toBeNull();
});
it('parses SCP-style ssh URLs into the host field', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const init = await engineInit(opFile);
const result = await engineRemoteSet(init.repoId, 'git@github.com:foo/bar.git');
expect(result.host).toBe('github.com');
expect(result.url).toBe('git@github.com:foo/bar.git');
});
});
// ---------------------------------------------------------------------------
// Phase 7a: folder-mode divergent merge (system git gated)
// ---------------------------------------------------------------------------
/**
* Helper: set up two local folder-mode repos sharing a bare remote.
* Both have an initial commit with design.op. Returns paths for both clones.
*/
async function setupFolderClonePair(): Promise<{ aDir: string; bDir: string }> {
const remoteDir = join(temp.dir, 'remote.git');
const aDir = join(temp.dir, 'a');
const bDir = join(temp.dir, 'b');
await execFileAsync('git', ['init', '--bare', '-b', 'main', remoteDir]);
await execFileAsync('git', ['clone', remoteDir, aDir]);
await execFileAsync('git', ['-C', aDir, 'checkout', '-b', 'main']);
await fsp.writeFile(
join(aDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#ff0000' }] }),
);
await fsp.writeFile(join(aDir, 'README.md'), '# Base\n');
await execFileAsync('git', ['-C', aDir, 'add', '.']);
await execFileAsync('git', [
'-C',
aDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'base',
]);
await execFileAsync('git', ['-C', aDir, 'push', '-u', 'origin', 'main']);
await execFileAsync('git', ['clone', remoteDir, bDir]);
return { aDir, bDir };
}
describe('Phase 7a: folder-mode divergent merge (system git gated)', () => {
it.skipIf(!systemGitAvailable)(
'folder-mode divergent pull returns conflict when .op file conflicts',
async () => {
const { aDir, bDir } = await setupFolderClonePair();
// a makes a change to design.op and pushes.
await fsp.writeFile(
join(aDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }),
);
await execFileAsync('git', ['-C', aDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
aDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'a: blue',
]);
await execFileAsync('git', ['-C', aDir, 'push']);
// b makes a divergent change to design.op.
await fsp.writeFile(
join(bDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }),
);
await execFileAsync('git', ['-C', bDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
bDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'b: green',
]);
const bResult = await engineOpen(bDir, join(bDir, 'design.op'));
// Phase 7a: folder-mode divergent pull should now return conflict (not throw).
const pullResult = await enginePull(bResult.repoId);
expect(pullResult.result).toBe('conflict');
expect(pullResult.conflicts).toBeDefined();
expect(pullResult.conflicts!.nodeConflicts.length).toBeGreaterThan(0);
// design.op on disk must be readable JSON (not conflict markers).
const onDisk = await fsp.readFile(join(bDir, 'design.op'), 'utf-8');
expect(() => JSON.parse(onDisk)).not.toThrow();
},
);
it.skipIf(!systemGitAvailable)(
'folder-mode divergent pull returns conflict-non-op when only non-.op files conflict',
async () => {
const { aDir, bDir } = await setupFolderClonePair();
// a changes only README.md and pushes.
await fsp.writeFile(join(aDir, 'README.md'), '# From A\n');
await execFileAsync('git', ['-C', aDir, 'add', 'README.md']);
await execFileAsync('git', [
'-C',
aDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'a: readme',
]);
await execFileAsync('git', ['-C', aDir, 'push']);
// b also changes README.md (divergently) but leaves design.op alone.
await fsp.writeFile(join(bDir, 'README.md'), '# From B\n');
await execFileAsync('git', ['-C', bDir, 'add', 'README.md']);
await execFileAsync('git', [
'-C',
bDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'b: readme',
]);
const bResult = await engineOpen(bDir, join(bDir, 'design.op'));
const pullResult = await enginePull(bResult.repoId);
expect(pullResult.result).toBe('conflict-non-op');
},
);
it.skipIf(!systemGitAvailable)(
'engineStatus reports mergeInProgress from on-disk MERGE_HEAD after session close/reopen',
async () => {
const { aDir, bDir } = await setupFolderClonePair();
// Create divergent commits in a and b.
await fsp.writeFile(
join(aDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }),
);
await execFileAsync('git', ['-C', aDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
aDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'a',
]);
await execFileAsync('git', ['-C', aDir, 'push']);
await fsp.writeFile(
join(bDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }),
);
await execFileAsync('git', ['-C', bDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
bDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'b',
]);
// First session: open and pull (enters conflict).
const session1 = await engineOpen(bDir, join(bDir, 'design.op'));
await enginePull(session1.repoId);
await engineClose(session1.repoId);
// Clear in-memory sessions (simulate panel close/reopen).
clearAllSessions();
// Second session: reopen — must detect on-disk merge state.
const session2 = await engineOpen(bDir, join(bDir, 'design.op'));
const status = await engineStatus(session2.repoId);
expect(status.mergeInProgress).toBe(true);
},
);
it.skipIf(!systemGitAvailable)(
'engineApplyMerge with on-disk merge state resolves .op conflicts and creates merge commit',
async () => {
const { aDir, bDir } = await setupFolderClonePair();
await fsp.writeFile(
join(aDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }),
);
await execFileAsync('git', ['-C', aDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
aDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'a',
]);
await execFileAsync('git', ['-C', aDir, 'push']);
await fsp.writeFile(
join(bDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }),
);
await execFileAsync('git', ['-C', bDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
bDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'b',
]);
const bResult = await engineOpen(bDir, join(bDir, 'design.op'));
const pullResult = await enginePull(bResult.repoId);
expect(pullResult.result).toBe('conflict');
const conflictId = pullResult.conflicts!.nodeConflicts[0].id;
await engineResolveConflict(bResult.repoId, conflictId, { kind: 'theirs' });
const applied = await engineApplyMerge(bResult.repoId);
expect(applied.noop).toBe(false);
expect(applied.hash).toMatch(/^[a-f0-9]{40}$/);
// design.op must be readable JSON with theirs's fill.
const onDisk = JSON.parse(await fsp.readFile(join(bDir, 'design.op'), 'utf-8'));
expect(onDisk.children[0].fill).toBe('#0000ff');
// Session inflightMerge cleared.
const { getSession: gs } = await import('../repo-session');
const sess = gs(bResult.repoId)!;
expect(sess.inflightMerge).toBeNull();
},
);
it.skipIf(!systemGitAvailable)(
'engineApplyMerge throws merge-still-conflicted when non-.op files remain unresolved',
async () => {
const { aDir, bDir } = await setupFolderClonePair();
// Both change design.op and README.md.
await fsp.writeFile(
join(aDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }),
);
await fsp.writeFile(join(aDir, 'README.md'), '# A\n');
await execFileAsync('git', ['-C', aDir, 'add', '.']);
await execFileAsync('git', [
'-C',
aDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'a',
]);
await execFileAsync('git', ['-C', aDir, 'push']);
await fsp.writeFile(
join(bDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }),
);
await fsp.writeFile(join(bDir, 'README.md'), '# B\n');
await execFileAsync('git', ['-C', bDir, 'add', '.']);
await execFileAsync('git', [
'-C',
bDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'b',
]);
const bResult = await engineOpen(bDir, join(bDir, 'design.op'));
const pullResult = await enginePull(bResult.repoId);
expect(pullResult.result).toBe('conflict');
// Resolve the .op conflict.
const conflictId = pullResult.conflicts!.nodeConflicts[0].id;
await engineResolveConflict(bResult.repoId, conflictId, { kind: 'ours' });
// README.md is still unresolved → must throw merge-still-conflicted.
await expect(engineApplyMerge(bResult.repoId)).rejects.toMatchObject({
name: 'GitError',
code: 'merge-still-conflicted',
});
},
);
it.skipIf(!systemGitAvailable)(
'engineAbortMerge in folder mode aborts on-disk merge state',
async () => {
const { aDir, bDir } = await setupFolderClonePair();
await fsp.writeFile(
join(aDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }),
);
await execFileAsync('git', ['-C', aDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
aDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'a',
]);
await execFileAsync('git', ['-C', aDir, 'push']);
await fsp.writeFile(
join(bDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }),
);
await execFileAsync('git', ['-C', bDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
bDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'b',
]);
const bResult = await engineOpen(bDir, join(bDir, 'design.op'));
await enginePull(bResult.repoId);
await engineAbortMerge(bResult.repoId);
// Session inflightMerge cleared.
const { getSession: gs } = await import('../repo-session');
const sess = gs(bResult.repoId)!;
expect(sess.inflightMerge).toBeNull();
// On-disk MERGE_HEAD gone.
const { readMergeHead: rmh } = await import('../worktree-merge');
const mergeHead = await rmh(join(bDir, '.git'));
expect(mergeHead).toBeNull();
// design.op is clean JSON.
const onDisk = await fsp.readFile(join(bDir, 'design.op'), 'utf-8');
expect(() => JSON.parse(onDisk)).not.toThrow();
},
);
// -------------------------------------------------------------------------
// Issue 1 (engine assertion): rename conflict is classified as conflict-non-op
//
// When the feature branch renames the tracked .op file, git places it in
// conflict state as "deleted-by-them" (stage 3 missing for the original
// path). The engine detects that stage 3 blob is absent for the tracked
// file and classifies the merge as { result: 'conflict-non-op' } — the user
// must resolve the rename in a terminal.
// -------------------------------------------------------------------------
it.skipIf(!systemGitAvailable)(
'engineBranchMerge returns conflict-non-op when theirs renames the tracked .op file',
async () => {
const repoDir = join(temp.dir, 'repo-rename-engine');
await fsp.mkdir(repoDir, { recursive: true });
const g = (...args: string[]) => execFileAsync('git', args, { cwd: repoDir });
const gc = (...args: string[]) =>
execFileAsync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e.com', ...args], {
cwd: repoDir,
});
// Base: design.op on main.
await g('init', '-b', 'main');
await fsp.writeFile(
join(repoDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base' }] }),
);
await g('add', '.');
await gc('commit', '-m', 'base');
// feature branch: rename design.op → design-v2.op AND modify content.
await g('checkout', '-b', 'feature');
await fsp.rename(join(repoDir, 'design.op'), join(repoDir, 'design-v2.op'));
await fsp.writeFile(
join(repoDir, 'design-v2.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'theirs' }] }),
);
await g('add', '-A');
await gc('commit', '-m', 'rename to design-v2.op');
// main: make a divergent change on the ORIGINAL design.op.
await g('checkout', 'main');
await fsp.writeFile(
join(repoDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'ours' }] }),
);
await g('add', '.');
await gc('commit', '-m', 'ours');
// Restore design.op (it was renamed away on feature; main's checkout
// puts it back with 'ours' content).
// Open via engine with design.op as the tracked file.
const result = await engineOpen(repoDir, join(repoDir, 'design.op'));
expect(result.trackedFilePath).toBe(resolve(join(repoDir, 'design.op')));
// Also manually set the autosaves ref so the engine can resolve commits.
const isoGit = await import('isomorphic-git');
const fsMod = await import('node:fs');
const mainHash = await isoGit.resolveRef({
fs: fsMod,
dir: repoDir,
ref: 'refs/heads/main',
});
const { setRef } = await import('../git-iso');
const session = (await import('../repo-session')).getSession(result.repoId)!;
await setRef({
handle: session.handle,
ref: 'refs/openpencil/autosaves/main',
value: mainHash,
});
const featureHash = await isoGit.resolveRef({
fs: fsMod,
dir: repoDir,
ref: 'refs/heads/feature',
});
await setRef({
handle: session.handle,
ref: 'refs/openpencil/autosaves/feature',
value: featureHash,
});
// Merge feature into main.
const mergeResult = await engineBranchMerge(result.repoId, 'feature');
// ASSERTION: rename conflict → stage 3 blob missing for tracked file
// → classified as conflict-non-op (user must resolve rename in terminal).
expect(mergeResult.result).toBe('conflict-non-op');
// Cleanup: abort the merge so the temp directory can be removed cleanly.
await engineAbortMerge(result.repoId);
},
);
// -------------------------------------------------------------------------
// Issue 2: engineApplyMerge folder-mode noop path
// The `noop: true` branch is reached when inflightMerge is null AND
// MERGE_HEAD is absent (merge was committed externally, e.g. via terminal).
// -------------------------------------------------------------------------
it.skipIf(!systemGitAvailable)(
'engineApplyMerge returns noop=true when merge was already committed externally',
async () => {
const { aDir, bDir } = await setupFolderClonePair();
// Create a conflict between a and b.
await fsp.writeFile(
join(aDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }),
);
await execFileAsync('git', ['-C', aDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
aDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'a',
]);
await execFileAsync('git', ['-C', aDir, 'push']);
await fsp.writeFile(
join(bDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }),
);
await execFileAsync('git', ['-C', bDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
bDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'b',
]);
// Open b, pull to enter conflict state.
const bResult = await engineOpen(bDir, join(bDir, 'design.op'));
const pullResult = await enginePull(bResult.repoId);
expect(pullResult.result).toBe('conflict');
// Externally finalize the merge via sysFinalizeMerge (simulating a user
// resolving the conflict in a terminal). First write a resolved file.
await fsp.writeFile(
join(bDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#ff00ff' }] }),
);
const { sysStageFile: sf, sysFinalizeMerge: sfm } = await import('../worktree-merge');
await sf({ cwd: bDir, filepath: 'design.op' });
await sfm({
cwd: bDir,
message: 'merge resolved externally',
author: { name: 'External', email: 'ext@test.com' },
});
// Now clear the in-memory inflightMerge to simulate what the engine
// session holds after the external commit (inflightMerge still set from
// pullResult, but MERGE_HEAD is now gone).
// We simulate this by clearing the session's inflightMerge manually.
const { clearInflightMerge } = await import('../repo-session');
clearInflightMerge(bResult.repoId);
// engineApplyMerge should detect: no inflightMerge, no MERGE_HEAD →
// return { noop: true } with the current HEAD hash.
const applied = await engineApplyMerge(bResult.repoId);
expect(applied.noop).toBe(true);
expect(applied.hash).toMatch(/^[a-f0-9]{40}$/);
},
);
// -------------------------------------------------------------------------
// Issue 3: panel reopen without calling status() first (Option A contract)
//
// When MERGE_HEAD is present on disk but session.inflightMerge is null
// (e.g. panel closed and reopened mid-conflict), engineApplyMerge must
// throw a clear, caller-actionable error directing the caller to call
// status() first. This is the deliberate Option A contract.
//
// The Phase 7b renderer always calls refreshStatus() on conflict-panel
// entry, so this path is only hit by direct callers (CLI, test harnesses)
// that skip the status() call.
// -------------------------------------------------------------------------
it.skipIf(!systemGitAvailable)(
'engineStatus panel-reopen: returns reopenedMidMerge=true and tracked .op excluded from unresolvedFiles',
async () => {
const { aDir, bDir } = await setupFolderClonePair();
// Create a conflict between a and b on design.op.
await fsp.writeFile(
join(aDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }),
);
await execFileAsync('git', ['-C', aDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
aDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'a',
]);
await execFileAsync('git', ['-C', aDir, 'push']);
await fsp.writeFile(
join(bDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }),
);
await execFileAsync('git', ['-C', bDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
bDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'b',
]);
// First session: open and pull (enters conflict).
const session1 = await engineOpen(bDir, join(bDir, 'design.op'));
await enginePull(session1.repoId);
await engineClose(session1.repoId);
// Simulate panel close/reopen: clear all in-memory sessions.
clearAllSessions();
// Second session: reopen — must detect on-disk merge state.
const session2 = await engineOpen(bDir, join(bDir, 'design.op'));
const status = await engineStatus(session2.repoId);
// I2: must signal degraded panel-reopen mode.
expect(status.reopenedMidMerge).toBe(true);
// I2: tracked .op file must NOT appear in unresolvedFiles.
// (It is in the git index with stages 1/2/3, but filtering prevents
// the renderer from showing it as a misleading "non-op file".)
const trackedRel = 'design.op';
expect(status.unresolvedFiles).not.toContain(trackedRel);
},
);
it.skipIf(!systemGitAvailable)(
'engineApplyMerge throws merge-still-conflicted with actionable message when called without status() after panel reopen',
async () => {
const { aDir, bDir } = await setupFolderClonePair();
// Create a conflict in b.
await fsp.writeFile(
join(aDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }),
);
await execFileAsync('git', ['-C', aDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
aDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'a',
]);
await execFileAsync('git', ['-C', aDir, 'push']);
await fsp.writeFile(
join(bDir, 'design.op'),
JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }),
);
await execFileAsync('git', ['-C', bDir, 'add', 'design.op']);
await execFileAsync('git', [
'-C',
bDir,
'-c',
'user.name=t',
'-c',
'user.email=t@e.com',
'commit',
'-m',
'b',
]);
// Session 1: open and pull (enters conflict — MERGE_HEAD on disk).
const session1 = await engineOpen(bDir, join(bDir, 'design.op'));
await enginePull(session1.repoId);
await engineClose(session1.repoId);
// Simulate panel close/reopen: clear all in-memory sessions.
clearAllSessions();
// Session 2: reopen the same repo. inflightMerge is null (new session),
// but MERGE_HEAD is still on disk.
const session2 = await engineOpen(bDir, join(bDir, 'design.op'));
// Verify MERGE_HEAD is still on disk (the conflict is still active).
const { readMergeHead: rmh } = await import('../worktree-merge');
const mergeHead = await rmh(join(bDir, '.git'));
expect(mergeHead).not.toBeNull();
// Call engineApplyMerge WITHOUT calling status() first.
// Must throw merge-still-conflicted with a message that guides the caller.
await expect(engineApplyMerge(session2.repoId)).rejects.toMatchObject({
name: 'GitError',
code: 'merge-still-conflicted',
message: expect.stringMatching(/call status\(\) first/i),
});
},
);
});
});