// 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 }; 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), }); }, ); }); });