openpencil/apps/desktop/git/git-iso.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

683 lines
22 KiB
TypeScript

// apps/desktop/git/git-iso.ts
//
// Local git operations using isomorphic-git. This module is namespace-agnostic:
// callers pass ref names (e.g. 'refs/heads/main') as parameters. The engine
// in Phase 2 wraps these primitives with the actual ref naming convention
// for milestones (refs/heads/<branch>) and autosaves (refs/openpencil/autosaves/<branch>).
import * as fs from 'node:fs';
import { dirname, basename, resolve, join } from 'node:path';
import { mkdir } from 'node:fs/promises';
import * as git from 'isomorphic-git';
import { GitError } from './error';
import type { RepoDetectionFound } from './repo-detector';
export interface IsoRepoHandle {
/** worktree (parent dir of .op file in single-file mode; repo root in folder mode) */
dir: string;
/** absolute path to the gitdir */
gitdir: string;
mode: 'single-file' | 'folder';
}
export interface CommitMetaIso {
hash: string;
parentHashes: string[];
message: string;
author: { name: string; email: string; timestamp: number };
}
export interface InitOptions {
/** absolute path to the .op file (must already exist on disk) */
filePath: string;
/** branch name to initialize HEAD with; default 'main' */
defaultBranch?: string;
/** author for the initial empty commit; defaults to 'OpenPencil <noreply@openpencil>' */
authorName?: string;
authorEmail?: string;
}
/**
* Initialize a single-file repo at .op-history/<basename>.git next to the file.
* Idempotent: if the gitdir already exists with a HEAD, returns the existing
* handle without re-initializing.
*
* The new repo has:
* - HEAD pointing at refs/heads/<defaultBranch>
* - `core.worktree = ../..` written into the gitdir's config file so that
* a user running `git -C <gitdir> status` from the terminal sees the
* correct working tree (the parent dir of the .op file). isomorphic-git
* itself doesn't need this — it accepts `dir` explicitly on every call —
* but the spec documents the on-disk shape with this setting and the
* CLI inspection use case demands it.
* - No initial commit (the engine will create one with the file's current
* content via commitFile).
*/
export async function initSingleFile(opts: InitOptions): Promise<IsoRepoHandle> {
const absFile = resolve(opts.filePath);
const dir = dirname(absFile);
const baseName = basename(absFile);
const gitdir = resolve(dir, '.op-history', `${baseName}.git`);
const defaultBranch = opts.defaultBranch ?? 'main';
try {
// If a HEAD already exists, this is a re-init. Return the handle as-is.
if (fs.existsSync(join(gitdir, 'HEAD'))) {
return { dir, gitdir, mode: 'single-file' };
}
// Create the parent .op-history/ if needed.
await mkdir(gitdir, { recursive: true });
// isomorphic-git's init creates the gitdir layout. We pass `dir` and
// `gitdir` separately so the worktree is the file's parent dir, not
// a sibling of gitdir.
await git.init({
fs,
dir,
gitdir,
defaultBranch,
bare: false,
});
// Explicitly write core.worktree so terminal `git -C <gitdir>` sees the
// correct working tree. The path is relative to the gitdir: from
// <parent>/.op-history/<basename>.git → ../.. brings us back to <parent>.
// We also force core.bare = false because some isomorphic-git versions
// default it to true when dir != dirname(gitdir).
await git.setConfig({
fs,
gitdir,
path: 'core.worktree',
value: '../..',
});
await git.setConfig({
fs,
gitdir,
path: 'core.bare',
value: false,
});
return { dir, gitdir, mode: 'single-file' };
} catch (err) {
throw new GitError(
'init-failed',
`Failed to initialize single-file repo for ${opts.filePath}`,
{
cause: err,
detail: { filePath: opts.filePath, gitdir },
},
);
}
}
/**
* Open an existing repository given a successful detection result.
* This is a thin wrapper that just packages the detection into a handle and
* verifies the gitdir is readable.
*/
export async function openRepo(detection: RepoDetectionFound): Promise<IsoRepoHandle> {
try {
// Verify the gitdir is readable by reading HEAD.
const head = join(detection.gitdir, 'HEAD');
if (!fs.existsSync(head)) {
throw new GitError('open-failed', `gitdir has no HEAD: ${detection.gitdir}`);
}
return {
dir: detection.rootPath,
gitdir: detection.gitdir,
mode: detection.mode,
};
} catch (err) {
if (err instanceof GitError) throw err;
throw new GitError('open-failed', `Failed to open repository at ${detection.rootPath}`, {
cause: err,
});
}
}
/**
* Stage the given file and create a commit on the specified ref. The ref
* doesn't have to exist beforehand — if it doesn't, it's created at this
* commit (this is how the engine seeds an empty branch with its first
* autosave).
*
* Throws GitError 'commit-empty' if the working tree state for `filepath`
* is identical to the ref's current tip (no changes to commit).
*
* Implementation note: isomorphic-git's `commit()` accepts a `ref` argument
* and updates that ref directly (instead of HEAD). It also accepts an
* explicit `parent` array. So we don't need any low-level tree-writing
* primitives — just stage, detect-empty, then commit.
*/
export async function commitFile(opts: {
handle: IsoRepoHandle;
filepath: string;
ref: string;
message: string;
author: { name: string; email: string };
parents?: string[];
}): Promise<{ hash: string }> {
const { handle, filepath, ref, message, author } = opts;
try {
// Determine parent commit(s). If the caller passed parents explicitly,
// use those. Otherwise look up the current ref tip; if the ref doesn't
// exist, the new commit is a root commit (no parents).
let parents = opts.parents;
if (!parents) {
try {
const tip = await git.resolveRef({ fs, gitdir: handle.gitdir, ref });
parents = [tip];
} catch {
parents = [];
}
}
// Detect "no changes" by comparing the working tree blob hash for
// `filepath` to the blob hash recorded at that path in the parent
// commit's tree. Only valid for linear commits (exactly one parent).
//
// For root commits (no parents) the check doesn't apply. For merge
// commits (2+ parents) the new tree may legitimately match one parent
// but not the others — a merge that was fully resolved in favor of one
// side still records history and must not be rejected. The engine layer
// in Phase 2 owns the higher-level "is this merge meaningful" decision.
if (parents.length === 1) {
const fileBytes = await fs.promises.readFile(resolve(handle.dir, filepath));
const { oid: workOid } = await git.hashBlob({ object: fileBytes });
let parentBlobOid: string | undefined;
try {
const { oid } = await git.readBlob({
fs,
gitdir: handle.gitdir,
oid: parents[0],
filepath,
});
parentBlobOid = oid;
} catch {
// file didn't exist in parent → not empty, fall through to commit
}
if (parentBlobOid && parentBlobOid === workOid) {
throw new GitError('commit-empty', `No changes to commit for ${filepath} on ${ref}`, {
recoverable: true,
});
}
}
// Stage the file. isomorphic-git's add reads the working tree file
// and writes a blob into objects/, plus updates the index.
await git.add({ fs, dir: handle.dir, gitdir: handle.gitdir, filepath });
// Create the commit. Passing `ref` makes isomorphic-git update that
// ref instead of HEAD. Passing `parent` overrides the default
// (which would be HEAD's tip). Together this lets us write to e.g.
// refs/openpencil/autosaves/main without touching HEAD or refs/heads/main.
const ts = Math.floor(Date.now() / 1000);
const hash = await git.commit({
fs,
dir: handle.dir,
gitdir: handle.gitdir,
message,
ref,
parent: parents,
author: {
name: author.name,
email: author.email,
timestamp: ts,
timezoneOffset: 0,
},
committer: {
name: author.name,
email: author.email,
timestamp: ts,
timezoneOffset: 0,
},
});
return { hash };
} catch (err) {
if (err instanceof GitError) throw err;
throw new GitError('engine-crash', `commitFile failed for ${filepath} on ${ref}`, {
cause: err,
detail: { filepath, ref },
});
}
}
/**
* Read the contents of a file at a specific commit. Returns the blob as
* a UTF-8 string. Used by `restoreFileFromCommit` and by the engine's
* "promote autosave" flow which needs to read the autosave's tree blob.
*/
export async function readBlobAtCommit(opts: {
handle: IsoRepoHandle;
filepath: string;
commitHash: string;
}): Promise<string> {
const { handle, filepath, commitHash } = opts;
try {
const { blob } = await git.readBlob({
fs,
gitdir: handle.gitdir,
oid: commitHash,
filepath,
});
return new TextDecoder('utf-8').decode(blob);
} catch (err) {
throw new GitError('engine-crash', `readBlobAtCommit failed for ${filepath} at ${commitHash}`, {
cause: err,
detail: { filepath, commitHash },
});
}
}
/**
* Walk commits from a ref tip in reverse chronological order. Returns up to
* `depth` commits, oldest-last. If the ref doesn't exist, returns an empty
* array (rather than throwing) — this is convenient for the engine which
* may query autosave refs that haven't been created yet.
*/
export async function logForRef(opts: {
handle: IsoRepoHandle;
ref: string;
depth: number;
}): Promise<CommitMetaIso[]> {
const { handle, ref, depth } = opts;
try {
// Check existence first; missing ref → empty log.
try {
await git.resolveRef({ fs, gitdir: handle.gitdir, ref });
} catch {
return [];
}
const commits = await git.log({
fs,
gitdir: handle.gitdir,
ref,
depth,
});
return commits.map((c) => ({
hash: c.oid,
parentHashes: c.commit.parent,
message: c.commit.message,
author: {
name: c.commit.author.name,
email: c.commit.author.email,
timestamp: c.commit.author.timestamp,
},
}));
} catch (err) {
throw new GitError('engine-crash', `logForRef failed for ${ref}`, {
cause: err,
detail: { ref },
});
}
}
/**
* Read a file's contents at a commit and write them to the working tree.
* Does NOT create a new commit — that's the caller's responsibility (the
* engine's restore flow runs commitFile afterward to record the restore).
*
* The file is written via Node fs.writeFile, NOT via git checkout, because
* checkout would also update the index and we want the working tree to be
* dirty after a restore so the user sees pending changes.
*/
export async function restoreFileFromCommit(opts: {
handle: IsoRepoHandle;
filepath: string;
commitHash: string;
}): Promise<void> {
const { handle, filepath, commitHash } = opts;
try {
const content = await readBlobAtCommit({ handle, filepath, commitHash });
const absPath = resolve(handle.dir, filepath);
await fs.promises.writeFile(absPath, content, 'utf-8');
} catch (err) {
if (err instanceof GitError) throw err;
throw new GitError(
'engine-crash',
`restoreFileFromCommit failed for ${filepath} at ${commitHash}`,
{
cause: err,
detail: { filepath, commitHash },
},
);
}
}
/**
* List branches under a ref prefix. The prefix is RELATIVE to refs/, so
* 'heads' lists refs/heads/*, 'openpencil/autosaves' lists
* refs/openpencil/autosaves/*. Returns just the branch names (last segment),
* not the full ref paths.
*/
export async function listBranches(opts: {
handle: IsoRepoHandle;
prefix?: string;
}): Promise<string[]> {
const { handle } = opts;
const prefix = opts.prefix ?? 'heads';
try {
if (prefix === 'heads') {
// Use isomorphic-git's high-level API for the common case.
return await git.listBranches({ fs, gitdir: handle.gitdir });
}
// Custom prefix: walk the gitdir's refs/<prefix>/ directory directly.
const refsRoot = join(handle.gitdir, 'refs', prefix);
try {
const entries = await fs.promises.readdir(refsRoot, { withFileTypes: true });
return entries.filter((e) => e.isFile()).map((e) => e.name);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
} catch (err) {
if (err instanceof GitError) throw err;
throw new GitError('engine-crash', `listBranches failed for prefix ${prefix}`, {
cause: err,
detail: { prefix },
});
}
}
/**
* Create a new branch under refs/heads/. If `fromCommit` is omitted, the
* new branch points at the current HEAD. Throws GitError 'branch-exists'
* if the name is already taken.
*/
export async function createBranch(opts: {
handle: IsoRepoHandle;
name: string;
fromCommit?: string;
}): Promise<void> {
const { handle, name, fromCommit } = opts;
try {
// Check existence first.
const existing = await git.listBranches({ fs, gitdir: handle.gitdir });
if (existing.includes(name)) {
throw new GitError('branch-exists', `Branch ${name} already exists`);
}
if (fromCommit) {
// Write the ref directly to the specified commit.
await git.writeRef({
fs,
gitdir: handle.gitdir,
ref: `refs/heads/${name}`,
value: fromCommit,
force: false,
});
} else {
// Branch from current HEAD via the high-level API.
await git.branch({ fs, gitdir: handle.gitdir, ref: name, checkout: false });
}
} catch (err) {
if (err instanceof GitError) throw err;
throw new GitError('engine-crash', `createBranch failed for ${name}`, {
cause: err,
detail: { name, fromCommit },
});
}
}
/**
* Return true if the branch `name` is reachable from the tip of any OTHER
* branch in refs/heads. "Reachable" means either:
* - another branch's tip OID equals `name`'s tip (fast-forward case), OR
* - another branch descends from `name`'s tip (merged-via-merge-commit).
*
* The equal-tip short-circuit is load-bearing: isomorphic-git's
* `git.isDescendent` explicitly returns false when `oid === ancestor`
* (see node_modules/isomorphic-git/index.cjs:12094), so without this
* shortcut a branch that was just fast-forward merged (or a branch that was
* just created from HEAD and never advanced) would be incorrectly flagged
* as unmerged, blocking a legitimate delete.
*/
async function isBranchMergedAnywhere(opts: {
handle: IsoRepoHandle;
name: string;
}): Promise<boolean> {
const { handle, name } = opts;
const targetOid = await git.resolveRef({ fs, gitdir: handle.gitdir, ref: name });
const branches = await git.listBranches({ fs, gitdir: handle.gitdir });
for (const candidate of branches) {
if (candidate === name) continue;
const candidateOid = await git.resolveRef({ fs, gitdir: handle.gitdir, ref: candidate });
if (candidateOid === targetOid) return true; // equal tips = merged (fast-forward case)
const merged = await git.isDescendent({
fs,
dir: handle.dir,
gitdir: handle.gitdir,
oid: candidateOid,
ancestor: targetOid,
depth: -1,
});
if (merged) return true;
}
return false;
}
/**
* Delete a branch under refs/heads/. Throws GitError 'branch-current' if
* the branch is the active HEAD. Throws GitError 'branch-unmerged' if the
* branch's tip is not reachable from any other branch, unless `force` is
* set — in which case the branch is deleted unconditionally.
*
* Note: `isomorphic-git`'s `deleteBranch` has no `force` option of its own,
* so we implement the mergedness check ourselves above the low-level call.
*/
export async function deleteBranch(opts: {
handle: IsoRepoHandle;
name: string;
force?: boolean;
}): Promise<void> {
const { handle, name, force = false } = opts;
try {
const current = await getCurrentBranch({ handle });
if (current === name) {
throw new GitError('branch-current', `Cannot delete the current branch ${name}`);
}
if (!force) {
const merged = await isBranchMergedAnywhere({ handle, name });
if (!merged) {
throw new GitError('branch-unmerged', `Branch ${name} has unmerged commits`, {
detail: { name },
});
}
}
await git.deleteBranch({ fs, gitdir: handle.gitdir, ref: name });
} catch (err) {
if (err instanceof GitError) throw err;
throw new GitError('engine-crash', `deleteBranch failed for ${name}`, {
cause: err,
detail: { name, force },
});
}
}
/**
* Switch HEAD to a different branch and update the working tree's tracked
* file to that branch's tip. Uses filepaths-scoped checkout so other files
* in the worktree are untouched.
*/
export async function switchBranch(opts: {
handle: IsoRepoHandle;
name: string;
filepath: string;
}): Promise<void> {
const { handle, name, filepath } = opts;
try {
await git.checkout({
fs,
dir: handle.dir,
gitdir: handle.gitdir,
ref: name,
filepaths: [filepath],
force: true,
});
} catch (err) {
throw new GitError('engine-crash', `switchBranch failed for ${name}`, {
cause: err,
detail: { name, filepath },
});
}
}
/**
* Return the current branch name (without the 'refs/heads/' prefix), or
* null if HEAD is detached.
*/
export async function getCurrentBranch(opts: { handle: IsoRepoHandle }): Promise<string | null> {
const { handle } = opts;
try {
const branch = await git.currentBranch({ fs, gitdir: handle.gitdir, fullname: false });
return branch ?? null;
} catch (err) {
throw new GitError('engine-crash', `getCurrentBranch failed`, { cause: err });
}
}
/**
* Force a ref to point at the given commit hash. Creates the ref if it does
* not exist. Used by the engine's milestone commit flow to jump the
* `refs/openpencil/autosaves/<branch>` ref to a freshly written milestone,
* abandoning the intermediate autosave chain.
*/
export async function setRef(opts: {
handle: IsoRepoHandle;
ref: string;
value: string;
}): Promise<void> {
const { handle, ref, value } = opts;
try {
await git.writeRef({
fs,
gitdir: handle.gitdir,
ref,
value,
force: true,
});
} catch (err) {
throw new GitError('engine-crash', `setRef failed for ${ref}`, {
cause: err,
detail: { ref, value },
});
}
}
/**
* Look up the blob OID for `filepath` at the tip of `ref`. Returns null if
* the ref doesn't exist OR the file isn't present in that commit's tree.
* Never throws — used by workingDirty detection where missing refs/files are
* a normal case (fresh repo with no commits).
*/
export async function readBlobOidAt(opts: {
handle: IsoRepoHandle;
ref: string;
filepath: string;
}): Promise<string | null> {
const { handle, ref, filepath } = opts;
let tip: string;
try {
tip = await git.resolveRef({ fs, gitdir: handle.gitdir, ref });
} catch {
return null;
}
try {
const { oid } = await git.readBlob({
fs,
gitdir: handle.gitdir,
oid: tip,
filepath,
});
return oid;
} catch {
return null;
}
}
/**
* Phase 6a: write the single 'origin' remote in `.git/config`.
*
* - non-empty `url` → `git.addRemote({ remote: 'origin', url, force: true })`
* The `force: true` flag makes this an upsert (add OR update). isomorphic-git
* itself rejects an existing remote without `force`.
* - `null` url → `git.deleteRemote({ remote: 'origin' })`. Naturally
* idempotent: isomorphic-git's `deleteRemote` is implemented as
* `GitConfigManager.deleteSection('remote', 'origin')`, which is a
* filter over parsed config entries and never throws when the section
* is absent. We therefore do NOT wrap it in a try/catch — the only
* failure modes are real I/O errors (EACCES, ENOSPC, etc.) from
* `GitConfigManager.save`, and those must propagate so the UI can
* surface them instead of silently claiming "origin removed" while
* `.git/config` still holds the old url.
*
* This is a LOCAL config mutation only — never opens a network socket. Lives
* in git-iso.ts (not git-sys.ts) per the Phase 6a plan: there is no transport
* decision to make here, so the dispatch helper in git-engine.ts isn't needed.
*/
export async function writeRemoteOrigin(opts: {
handle: IsoRepoHandle;
url: string | null;
}): Promise<void> {
const { handle, url } = opts;
try {
if (url === null) {
await git.deleteRemote({ fs, gitdir: handle.gitdir, remote: 'origin' });
return;
}
await git.addRemote({
fs,
gitdir: handle.gitdir,
remote: 'origin',
url,
force: true, // upsert
});
} catch (err) {
throw new GitError('engine-crash', `writeRemoteOrigin failed`, {
cause: err,
detail: { url },
});
}
}
/**
* Find the merge base (common ancestor) of two commits. Used by the merge
* orchestrator to load the `base` document for 3-way merge.
*
* Throws GitError 'engine-crash' if no merge base exists (unrelated
* histories) — Phase 2c does not support merging unrelated histories.
*/
export async function findMergeBase(opts: {
handle: IsoRepoHandle;
oid1: string;
oid2: string;
}): Promise<string> {
const { handle, oid1, oid2 } = opts;
try {
const oids = await git.findMergeBase({
fs,
gitdir: handle.gitdir,
oids: [oid1, oid2],
});
if (!oids || oids.length === 0) {
throw new GitError(
'engine-crash',
`No merge base for ${oid1} and ${oid2} (unrelated histories)`,
);
}
return oids[0];
} catch (err) {
if (err instanceof GitError) throw err;
throw new GitError('engine-crash', `findMergeBase failed`, {
cause: err,
detail: { oid1, oid2 },
});
}
}