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

308 lines
11 KiB
TypeScript

// apps/desktop/git/worktree-merge.ts
//
// System-git helpers for folder-mode merge operations. These are the only
// functions in the git layer that shell out to the system git binary for
// merge state management — everything else uses isomorphic-git.
//
// DESIGN NOTE (Phase 7a spike):
// We use system git's merge machinery because isomorphic-git has no
// equivalent of --no-commit --no-ff merges and cannot write the three-stage
// index entries needed for conflict detection. The exact command sequence
// was chosen after verifying each shape against a live repo:
//
// 1. `git merge --no-commit --no-ff <ref>` — enters merge state; exits 1
// on conflicts, exits 0 on clean merge (but still --no-commit so we
// can write the tracked file before committing).
// 2. `git ls-files -u` — lists all unresolved paths (all conflict types,
// not just "both modified"), along with stage numbers 1/2/3.
// 3. `git show :1:<path>`, `:2:<path>`, `:3:<path>` — reads base/ours/
// theirs blobs from the index without touching the working tree.
// 4. `git checkout --ours -- <file>` — writes the ours version to disk so
// the tracked .op file is readable JSON; file stays "unresolved" in the
// index so MERGE_HEAD and other unresolved files survive.
// 5. `git add <file>` — marks a file resolved in the index.
// 6. `git commit -m <message>` — when MERGE_HEAD is present, git
// automatically creates a 2-parent merge commit.
// 7. `git merge --abort` — atomically restores the working tree and index.
import { execFile } from 'node:child_process';
import { promises as fsp } from 'node:fs';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { GitError } from './error';
const execFileAsync = promisify(execFile);
const DEFAULT_TIMEOUT_MS = 60_000;
interface RunOpts {
cwd: string;
env?: Record<string, string>;
timeoutMs?: number;
}
interface RunResult {
stdout: string;
stderr: string;
exitCode: number;
}
/**
* Run `git <args>`. Unlike the private runGit in git-sys.ts, this version
* tolerates non-zero exits and returns the exit code so callers can
* distinguish "conflict" from "error" — `git merge` exits 1 on conflicts
* but that is not an error from the caller's perspective.
*/
async function runGitTolerant(args: string[], opts: RunOpts): Promise<RunResult> {
const env = { ...process.env, ...opts.env };
try {
const { stdout, stderr } = await execFileAsync('git', args, {
cwd: opts.cwd,
env,
timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
maxBuffer: 32 * 1024 * 1024,
});
return { stdout, stderr, exitCode: 0 };
} catch (err) {
const e = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string; code?: number };
// exitCode is the numeric exit code from the child process; undefined if it
// was killed by a signal (which we map to -1).
const exitCode = typeof e.code === 'number' ? e.code : -1;
return {
stdout: e.stdout ?? '',
stderr: e.stderr ?? '',
exitCode,
};
}
}
// ---------------------------------------------------------------------------
// Public helpers
// ---------------------------------------------------------------------------
/**
* Attempt to merge `ref` into the current branch without auto-committing.
* Uses `--no-ff` to always produce a merge commit even for fast-forwards.
*
* Returns:
* - { kind: 'clean' } — merge succeeded with no conflicts; index is staged
* but not committed (MERGE_HEAD is set).
* - { kind: 'conflict' } — one or more conflicts; MERGE_HEAD is set, unresolved
* files remain in the index at conflict stages.
* - throws GitError — on engine-level failures (not available, unknown ref, etc.)
*
* NOTE: some git versions read user identity during merge bookkeeping even with
* --no-commit. Callers must ensure the repo has user.name/user.email configured
* (or inject them via opts.env) — machines without a global git config will fail.
*/
export async function sysMergeNoCommit(opts: {
cwd: string;
ref: string;
env?: Record<string, string>;
}): Promise<{ kind: 'clean' | 'conflict' }> {
const result = await runGitTolerant(['merge', '--no-commit', '--no-ff', opts.ref], {
cwd: opts.cwd,
env: opts.env,
});
if (result.exitCode === 0) return { kind: 'clean' };
// Exit code 1 from `git merge` means conflicts. Any other code is an error.
if (result.exitCode === 1) return { kind: 'conflict' };
throw new GitError(
'engine-crash',
`git merge --no-commit failed: ${result.stderr.trim() || result.stdout.trim()}`,
{ detail: { ref: opts.ref, exitCode: result.exitCode } },
);
}
/**
* List all unresolved file paths in the current merge state.
* Uses `git ls-files -u` which reports ALL conflict types (both-modified,
* deleted-by-them, etc.), not just `--diff-filter=U` which only reports
* "both modified". Returns deduplicated, sorted paths.
*
* MINIMUM GIT VERSION: `--format=%(path)` requires git ≥ 2.35 (Feb 2022).
* No version check or fallback is provided here — callers must ensure the
* system git is new enough. Document this floor in deployment requirements.
*/
export async function sysListUnresolved(opts: { cwd: string }): Promise<string[]> {
const result = await runGitTolerant(['ls-files', '-u', '--format=%(path)'], {
cwd: opts.cwd,
});
if (result.exitCode !== 0) {
throw new GitError('engine-crash', `git ls-files -u failed: ${result.stderr.trim()}`, {
detail: { exitCode: result.exitCode },
});
}
const paths = result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
// Deduplicate: each unresolved path appears 2-3 times (one per stage).
return [...new Set(paths)].sort();
}
/**
* Detect whether a merge is in progress by checking for MERGE_HEAD in the
* gitdir. Does NOT run git — pure filesystem check. Returns the theirs
* commit hash if in progress, null otherwise.
*/
export async function readMergeHead(gitdir: string): Promise<string | null> {
const mergeHeadPath = join(gitdir, 'MERGE_HEAD');
try {
const content = await fsp.readFile(mergeHeadPath, 'utf-8');
const hash = content.trim();
if (hash.length === 40) return hash;
return null;
} catch {
return null;
}
}
/**
* Read the content of a tracked file from the index at a specific stage:
* stage 1 = base (merge-base ancestor)
* stage 2 = ours (HEAD)
* stage 3 = theirs (MERGE_HEAD)
*
* Returns null if the file is not present at that stage (e.g. deleted-by-them
* conflict has no stage 3, only stages 1 and 2).
*/
export async function sysShowStageBlob(opts: {
cwd: string;
stage: 1 | 2 | 3;
filepath: string;
}): Promise<string | null> {
const stageRef = `:${opts.stage}:${opts.filepath}`;
const result = await runGitTolerant(['show', stageRef], { cwd: opts.cwd });
if (result.exitCode === 0) return result.stdout;
// Non-zero exit means the file doesn't exist at this stage — that is not
// an error, it's a normal state (e.g. deleted-by-them has no :3:).
return null;
}
/**
* Restore the working-tree content of a tracked file to the "ours" version
* (stage 2) so the renderer can read readable JSON instead of conflict
* markers. The file stays "unresolved" in the index — MERGE_HEAD survives.
*
* The exact behaviour was verified in the Phase 7a spike:
* `git checkout --ours -- <file>` writes stage 2 to disk and leaves the
* index at conflict stages (1/2/3). `git diff --name-only --diff-filter=U`
* still reports the file as unresolved after this call.
*/
export async function sysRestoreOurs(opts: { cwd: string; filepath: string }): Promise<void> {
const result = await runGitTolerant(['checkout', '--ours', '--', opts.filepath], {
cwd: opts.cwd,
});
if (result.exitCode !== 0) {
throw new GitError(
'engine-crash',
`git checkout --ours failed for ${opts.filepath}: ${result.stderr.trim()}`,
{ detail: { filepath: opts.filepath, exitCode: result.exitCode } },
);
}
}
/**
* Stage a file, marking it as resolved in the index. Used after the tracked
* .op file has been written with the final merged document so git accepts the
* merge commit.
*/
export async function sysStageFile(opts: { cwd: string; filepath: string }): Promise<void> {
const result = await runGitTolerant(['add', '--', opts.filepath], { cwd: opts.cwd });
if (result.exitCode !== 0) {
throw new GitError(
'engine-crash',
`git add failed for ${opts.filepath}: ${result.stderr.trim()}`,
{ detail: { filepath: opts.filepath, exitCode: result.exitCode } },
);
}
}
/**
* Finalize the merge by creating the merge commit. MERGE_HEAD must be set.
* When MERGE_HEAD is present, git automatically records both parents.
*
* Returns the new merge commit hash.
*/
export async function sysFinalizeMerge(opts: {
cwd: string;
message: string;
author: { name: string; email: string };
env?: Record<string, string>;
}): Promise<string> {
const env: Record<string, string> = {
...opts.env,
GIT_AUTHOR_NAME: opts.author.name,
GIT_AUTHOR_EMAIL: opts.author.email,
GIT_COMMITTER_NAME: opts.author.name,
GIT_COMMITTER_EMAIL: opts.author.email,
};
const result = await runGitTolerant(['commit', '-m', opts.message], {
cwd: opts.cwd,
env,
});
if (result.exitCode !== 0) {
throw new GitError(
'engine-crash',
`git commit (merge finalize) failed: ${result.stderr.trim()}`,
{ detail: { exitCode: result.exitCode } },
);
}
// Parse the new commit hash from `git rev-parse HEAD`.
const headResult = await runGitTolerant(['rev-parse', 'HEAD'], { cwd: opts.cwd });
if (headResult.exitCode !== 0 || !headResult.stdout.trim()) {
throw new GitError('engine-crash', 'Failed to read HEAD after merge commit');
}
return headResult.stdout.trim();
}
/**
* Abort an in-progress merge. Restores the working tree and index to pre-merge
* state. Idempotent: safe to call even if no merge is in progress (git merge
* --abort exits 0 with a warning in that case on modern git versions).
*/
export async function sysAbortMerge(opts: { cwd: string }): Promise<void> {
const result = await runGitTolerant(['merge', '--abort'], { cwd: opts.cwd });
// Exit code 0 = success. Exit code 128 with "MERGE_HEAD missing" means there
// was no merge in progress — treat that as idempotent success.
if (result.exitCode === 0) return;
const msg = (result.stderr + result.stdout).toLowerCase();
if (msg.includes('merge_head') || msg.includes('no merge in progress')) {
return; // Nothing to abort — already clean.
}
throw new GitError('merge-abort-failed', `git merge --abort failed: ${result.stderr.trim()}`, {
detail: { exitCode: result.exitCode },
});
}
/**
* Read the current HEAD commit hash. Throws if HEAD cannot be resolved
* (e.g. repo has no commits).
*/
export async function sysReadHead(opts: { cwd: string }): Promise<string> {
const result = await runGitTolerant(['rev-parse', 'HEAD'], { cwd: opts.cwd });
if (result.exitCode !== 0 || !result.stdout.trim()) {
throw new GitError('engine-crash', `git rev-parse HEAD failed: ${result.stderr.trim()}`, {
detail: { exitCode: result.exitCode },
});
}
return result.stdout.trim();
}