openpencil/apps/desktop/git/__tests__/git-iso.test.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

654 lines
23 KiB
TypeScript

// apps/desktop/git/__tests__/git-iso.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import {
initSingleFile,
openRepo,
commitFile,
readBlobAtCommit,
logForRef,
restoreFileFromCommit,
listBranches,
createBranch,
deleteBranch,
switchBranch,
getCurrentBranch,
setRef,
readBlobOidAt,
findMergeBase,
} from '../git-iso';
import { detectRepo } from '../repo-detector';
import { mkTempDir, writeOpFile } from './test-helpers';
describe('git-iso', () => {
let temp: { dir: string; dispose: () => Promise<void> };
beforeEach(async () => {
temp = await mkTempDir();
});
afterEach(async () => {
await temp.dispose();
});
describe('initSingleFile', () => {
it('creates .op-history/<basename>.git/ with HEAD pointing at refs/heads/main', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const handle = await initSingleFile({ filePath: opFile });
expect(handle.mode).toBe('single-file');
expect(handle.dir).toBe(temp.dir);
expect(handle.gitdir).toBe(join(temp.dir, '.op-history', 'login.op.git'));
expect(existsSync(join(handle.gitdir, 'HEAD'))).toBe(true);
});
it('is idempotent: re-running init on an existing repo returns the same handle without erroring', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const first = await initSingleFile({ filePath: opFile });
const second = await initSingleFile({ filePath: opFile });
expect(second.gitdir).toBe(first.gitdir);
expect(second.dir).toBe(first.dir);
expect(existsSync(join(second.gitdir, 'HEAD'))).toBe(true);
});
it('respects the defaultBranch option', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const handle = await initSingleFile({ filePath: opFile, defaultBranch: 'trunk' });
// isomorphic-git writes HEAD as a symbolic ref string
const fs = await import('node:fs/promises');
const head = await fs.readFile(join(handle.gitdir, 'HEAD'), 'utf-8');
expect(head.trim()).toBe('ref: refs/heads/trunk');
});
it('writes core.worktree = ../.. so terminal git can inspect the repo', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const handle = await initSingleFile({ filePath: opFile });
// Read the on-disk config and verify both core.worktree and core.bare.
const fsp = await import('node:fs/promises');
const config = await fsp.readFile(join(handle.gitdir, 'config'), 'utf-8');
expect(config).toMatch(/worktree\s*=\s*\.\.\/\.\./);
expect(config).toMatch(/bare\s*=\s*false/);
});
});
describe('openRepo', () => {
it('opens an existing single-file repo via a successful detection', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
await initSingleFile({ filePath: opFile });
const detection = await detectRepo(opFile);
expect(detection.mode).toBe('single-file');
if (detection.mode !== 'single-file') throw new Error('detection failed');
const handle = await openRepo(detection);
expect(handle.mode).toBe('single-file');
expect(handle.dir).toBe(temp.dir);
});
});
describe('commitFile + readBlobAtCommit', () => {
it('creates a commit on the given ref and the blob round-trips', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
const { hash } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'initial',
author: { name: 'tester', email: 'tester@example.com' },
});
expect(hash).toMatch(/^[a-f0-9]{40}$/);
const content = await readBlobAtCommit({
handle,
filepath: 'login.op',
commitHash: hash,
});
const parsed = JSON.parse(content);
expect(parsed.children[0].id).toBe('r1');
});
it('throws commit-empty when committing the same content twice', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const handle = await initSingleFile({ filePath: opFile });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await expect(
commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'second',
author: { name: 't', email: 't@example.com' },
}),
).rejects.toMatchObject({ name: 'GitError', code: 'commit-empty' });
});
it('chains commits: each commit has the previous as parent', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
const { hash: h1 } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
// Mutate the file then commit again.
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
const { hash: h2 } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'second',
author: { name: 't', email: 't@example.com' },
});
expect(h2).not.toBe(h1);
// Verify h1 content is recoverable
const c1 = await readBlobAtCommit({ handle, filepath: 'login.op', commitHash: h1 });
expect(JSON.parse(c1).children[0].id).toBe('r1');
// Verify h2 content
const c2 = await readBlobAtCommit({ handle, filepath: 'login.op', commitHash: h2 });
expect(JSON.parse(c2).children[0].id).toBe('r2');
});
});
describe('logForRef', () => {
it('returns commits in reverse chronological order (newest first)', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
const { hash: h1 } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
const { hash: h2 } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'second',
author: { name: 't', email: 't@example.com' },
});
const log = await logForRef({ handle, ref: 'refs/heads/main', depth: 10 });
expect(log).toHaveLength(2);
expect(log[0].hash).toBe(h2);
expect(log[0].message).toBe('second\n');
expect(log[1].hash).toBe(h1);
expect(log[1].message).toBe('first\n');
});
it('returns an empty array for a ref that does not exist', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const handle = await initSingleFile({ filePath: opFile });
const log = await logForRef({
handle,
ref: 'refs/openpencil/autosaves/main',
depth: 10,
});
expect(log).toEqual([]);
});
it('respects the depth parameter', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
// Make 3 commits.
for (let i = 0; i < 3; i++) {
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: `r${i}` }],
});
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: `commit-${i}`,
author: { name: 't', email: 't@example.com' },
});
}
const log = await logForRef({ handle, ref: 'refs/heads/main', depth: 2 });
expect(log).toHaveLength(2);
});
});
describe('restoreFileFromCommit', () => {
it("writes a previous commit's content to the working tree", async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
const { hash: h1 } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
// Mutate
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
// Restore
await restoreFileFromCommit({ handle, filepath: 'login.op', commitHash: h1 });
// Verify the working tree file was overwritten
const fsp = await import('node:fs/promises');
const restored = await fsp.readFile(opFile, 'utf-8');
expect(JSON.parse(restored).children[0].id).toBe('r1');
});
it('does NOT create a new commit (the caller is responsible)', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
const { hash: h1 } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'second',
author: { name: 't', email: 't@example.com' },
});
const logBefore = await logForRef({ handle, ref: 'refs/heads/main', depth: 10 });
await restoreFileFromCommit({ handle, filepath: 'login.op', commitHash: h1 });
const logAfter = await logForRef({ handle, ref: 'refs/heads/main', depth: 10 });
expect(logAfter).toHaveLength(logBefore.length); // unchanged
});
});
describe('branch operations', () => {
it('listBranches returns empty for a fresh repo with no commits', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op');
const handle = await initSingleFile({ filePath: opFile });
const branches = await listBranches({ handle });
expect(branches).toEqual([]); // no commits = no branches
});
it('listBranches returns the branch after a commit creates it', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
const branches = await listBranches({ handle });
expect(branches).toContain('main');
});
it('createBranch from current HEAD adds a new branch', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await createBranch({ handle, name: 'feature-x' });
const branches = await listBranches({ handle });
expect(branches).toContain('feature-x');
expect(branches).toContain('main');
});
it('createBranch throws branch-exists when the name is taken', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await createBranch({ handle, name: 'feature-x' });
await expect(createBranch({ handle, name: 'feature-x' })).rejects.toMatchObject({
name: 'GitError',
code: 'branch-exists',
});
});
it('deleteBranch throws branch-current when deleting the active branch', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await expect(deleteBranch({ handle, name: 'main' })).rejects.toMatchObject({
name: 'GitError',
code: 'branch-current',
});
});
it('deleteBranch removes a non-active branch', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
await createBranch({ handle, name: 'feature-x' });
await deleteBranch({ handle, name: 'feature-x' });
const branches = await listBranches({ handle });
expect(branches).not.toContain('feature-x');
expect(branches).toContain('main');
});
it('deleteBranch throws branch-unmerged for an unmerged branch without force', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'base',
author: { name: 't', email: 't@example.com' },
});
await createBranch({ handle, name: 'feature-x' });
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r2' }],
});
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/feature-x',
message: 'feature only',
author: { name: 't', email: 't@example.com' },
});
await expect(deleteBranch({ handle, name: 'feature-x' })).rejects.toMatchObject({
name: 'GitError',
code: 'branch-unmerged',
});
});
it('deleteBranch removes an unmerged branch when force=true', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'base',
author: { name: 't', email: 't@example.com' },
});
await createBranch({ handle, name: 'feature-x' });
await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r2' }],
});
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/feature-x',
message: 'feature only',
author: { name: 't', email: 't@example.com' },
});
await deleteBranch({ handle, name: 'feature-x', force: true });
const branches = await listBranches({ handle });
expect(branches).toEqual(['main']);
});
it('deleteBranch treats a fast-forward-merged branch as merged (equal OID tips)', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'base',
author: { name: 't', email: 't@example.com' },
});
// Branch off main with no additional commits: feature-ff tip === main tip.
await createBranch({ handle, name: 'feature-ff' });
// Without the equal-OID short-circuit in isBranchMergedAnywhere this would
// throw branch-unmerged because isomorphic-git's isDescendent returns
// false when oid === ancestor.
await deleteBranch({ handle, name: 'feature-ff' });
const branches = await listBranches({ handle });
expect(branches).toEqual(['main']);
});
it('getCurrentBranch returns the active branch name', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
const current = await getCurrentBranch({ handle });
expect(current).toBe('main');
});
it('switchBranch updates the working tree file to the target branch tip', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
// Branch off, then commit something different on the new branch.
await createBranch({ handle, name: 'feature-x' });
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/feature-x',
message: 'feature change',
author: { name: 't', email: 't@example.com' },
});
// Switch back to main and verify the file content reverts.
await switchBranch({ handle, name: 'main', filepath: 'login.op' });
const fsp = await import('node:fs/promises');
const content = await fsp.readFile(opFile, 'utf-8');
expect(JSON.parse(content).children[0].id).toBe('r1');
});
});
describe('setRef + readBlobOidAt', () => {
it('setRef creates a new ref pointing at the given commit and force-overwrites an existing one', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
const { hash: h1 } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
// Create a brand-new ref pointing at h1.
await setRef({ handle, ref: 'refs/openpencil/autosaves/main', value: h1 });
const log1 = await logForRef({
handle,
ref: 'refs/openpencil/autosaves/main',
depth: 10,
});
expect(log1).toHaveLength(1);
expect(log1[0].hash).toBe(h1);
// Make a second milestone, then force the autosave ref to it (overwrite).
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
const { hash: h2 } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'second',
author: { name: 't', email: 't@example.com' },
});
await setRef({ handle, ref: 'refs/openpencil/autosaves/main', value: h2 });
const log2 = await logForRef({
handle,
ref: 'refs/openpencil/autosaves/main',
depth: 10,
});
// The autosave ref now jumps to h2 — its log walks h2 → h1 (parent chain).
expect(log2[0].hash).toBe(h2);
});
it('readBlobOidAt returns the blob OID at the ref tip and null for missing ref/file', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
// Before any commit, the ref doesn't exist → null.
const before = await readBlobOidAt({
handle,
ref: 'refs/heads/main',
filepath: 'login.op',
});
expect(before).toBeNull();
await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'first',
author: { name: 't', email: 't@example.com' },
});
// After commit, the OID is a 40-char hex string.
const after = await readBlobOidAt({
handle,
ref: 'refs/heads/main',
filepath: 'login.op',
});
expect(after).toMatch(/^[a-f0-9]{40}$/);
// Asking for a file that's not in the commit's tree → null.
const missing = await readBlobOidAt({
handle,
ref: 'refs/heads/main',
filepath: 'does-not-exist.op',
});
expect(missing).toBeNull();
});
});
describe('findMergeBase', () => {
it('returns the common ancestor of two divergent branches', async () => {
const opFile = await writeOpFile(temp.dir, 'login.op', {
version: '1.0.0',
children: [{ id: 'r1' }],
});
const handle = await initSingleFile({ filePath: opFile });
const { hash: base } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/main',
message: 'base',
author: { name: 't', email: 't@example.com' },
});
// Branch off and commit on each branch.
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
const { hash: ours } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/feature-a',
message: 'ours',
author: { name: 't', email: 't@example.com' },
parents: [base],
});
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r3' }] });
const { hash: theirs } = await commitFile({
handle,
filepath: 'login.op',
ref: 'refs/heads/feature-b',
message: 'theirs',
author: { name: 't', email: 't@example.com' },
parents: [base],
});
const mergeBase = await findMergeBase({ handle, oid1: ours, oid2: theirs });
expect(mergeBase).toBe(base);
});
});
});