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

295 lines
8.7 KiB
TypeScript

// apps/desktop/git/git-sys.ts
//
// System git wrapper. Phase 2b makes this real: clone/fetch/push/pull-FF
// run via execFile, returning typed results or throwing GitError on
// recognized failure modes.
//
// All ops accept an optional env map so SSH callers can set
// GIT_SSH_COMMAND='ssh -i <keyPath> -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new'
// without affecting the parent process env.
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { GitError, type GitErrorCode } from './error';
const execFileAsync = promisify(execFile);
let cached: boolean | undefined;
const DEFAULT_TIMEOUT_MS = 60_000;
export async function isSystemGitAvailable(): Promise<boolean> {
if (cached !== undefined) return cached;
try {
await execFileAsync('git', ['--version'], { timeout: 5000 });
cached = true;
} catch {
cached = false;
}
return cached;
}
export function __resetSystemGitCache(): void {
cached = undefined;
}
interface RunOpts {
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
}
interface RunResult {
stdout: string;
stderr: string;
}
/**
* Run `git <args>` with the given env and cwd. On failure, maps stderr to a
* GitError code and throws. Used by all the higher-level fns below.
*/
async function runGit(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 };
} catch (err) {
const e = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string };
const stderr = e.stderr ?? '';
const code = mapSysError(stderr, e);
throw new GitError(code, `git ${args.join(' ')} failed: ${stderr.trim() || e.message}`, {
cause: err,
detail: { args, stderr },
});
}
}
/**
* Map stderr text from a failed `git` invocation to a GitError code.
* Patterns are intentionally simple substring checks — git's error messages
* are not localized and have been stable for many years.
*/
export function mapSysError(stderr: string, err?: { message?: string }): GitErrorCode {
const s = stderr.toLowerCase();
const msg = (err?.message ?? '').toLowerCase();
// Auth failures
if (
s.includes('authentication failed') ||
s.includes('could not read username') ||
s.includes('permission denied (publickey)') ||
s.includes('access denied')
) {
return 'auth-failed';
}
// Repository missing
if (s.includes('repository not found') || s.includes('does not exist')) {
return 'clone-failed';
}
// Clone target already exists
if (s.includes('already exists and is not an empty directory')) {
return 'clone-target-exists';
}
// Network failures
if (
s.includes("couldn't resolve host") ||
s.includes('could not resolve hostname') ||
s.includes('connection refused') ||
s.includes('no route to host')
) {
return 'network';
}
// Timeouts (both git's own and execFile's)
if (
s.includes('connection timed out') ||
s.includes('operation timed out') ||
msg.includes('etimedout')
) {
return 'timeout';
}
// Push rejected (non-FF)
if (s.includes('updates were rejected') || s.includes('non-fast-forward')) {
return 'push-rejected';
}
// Pull non-FF (we run --ff-only so any divergence yields this)
if (s.includes('not possible to fast-forward')) {
return 'pull-non-fast-forward';
}
// Not a repo
if (s.includes('not a git repository') || s.includes('does not appear to be a git repository')) {
return 'not-a-repo';
}
return 'engine-crash';
}
// ---------------------------------------------------------------------------
// Public ops
// ---------------------------------------------------------------------------
export async function sysClone(opts: {
url: string;
dest: string;
env?: Record<string, string>;
timeoutMs?: number;
}): Promise<void> {
await runGit(['clone', opts.url, opts.dest], {
env: opts.env,
timeoutMs: opts.timeoutMs,
});
}
export async function sysFetch(opts: {
cwd: string;
remote?: string;
env?: Record<string, string>;
timeoutMs?: number;
}): Promise<void> {
const remote = opts.remote ?? 'origin';
await runGit(['fetch', remote], {
cwd: opts.cwd,
env: opts.env,
timeoutMs: opts.timeoutMs,
});
}
export async function sysPullFastForward(opts: {
cwd: string;
remote?: string;
branch: string;
env?: Record<string, string>;
timeoutMs?: number;
}): Promise<{ result: 'fast-forward' | 'up-to-date' }> {
const remote = opts.remote ?? 'origin';
// --ff-only refuses to merge; if the remote diverged, throws pull-non-fast-forward.
const { stdout } = await runGit(['pull', '--ff-only', remote, opts.branch], {
cwd: opts.cwd,
env: opts.env,
timeoutMs: opts.timeoutMs,
});
if (stdout.includes('Already up to date')) return { result: 'up-to-date' };
return { result: 'fast-forward' };
}
export async function sysPush(opts: {
cwd: string;
remote?: string;
branch: string;
env?: Record<string, string>;
timeoutMs?: number;
}): Promise<void> {
const remote = opts.remote ?? 'origin';
await runGit(['push', remote, opts.branch], {
cwd: opts.cwd,
env: opts.env,
timeoutMs: opts.timeoutMs,
});
}
/**
* Compute ahead/behind counts for the current branch vs `<remote>/<branch>`.
* Returns 0/0 if no remote-tracking ref exists for the branch.
*/
export async function sysAheadBehind(opts: {
cwd: string;
branch: string;
remote?: string;
env?: Record<string, string>;
}): Promise<{ ahead: number; behind: number }> {
const remote = opts.remote ?? 'origin';
try {
const { stdout } = await runGit(
['rev-list', '--left-right', '--count', `${opts.branch}...${remote}/${opts.branch}`],
{ cwd: opts.cwd, env: opts.env },
);
// Output format: "<ahead>\t<behind>\n"
const [aheadStr, behindStr] = stdout.trim().split(/\s+/);
return {
ahead: parseInt(aheadStr, 10) || 0,
behind: parseInt(behindStr, 10) || 0,
};
} catch (err) {
// No remote tracking ref → not an error, just zeros.
if (err instanceof GitError && err.code === 'engine-crash') {
return { ahead: 0, behind: 0 };
}
throw err;
}
}
/**
* Executor shape used by getSystemAuthor's test seam. Mirrors runGit's return
* type so tests can inject a fake without pulling in node:child_process.
*/
type RunGitExec = (args: string[]) => Promise<{ stdout: string; stderr: string }>;
/**
* Read `user.name` / `user.email` from the system git config. Used by Phase 4a
* as step 2 of the author identity lookup chain (prefs → sysGit → form).
*
* The optional `injectedExec` parameter is a TEST SEAM: production callers pass
* no args and get the real `runGit`-backed path, while tests inject a fake to
* deterministically exercise both the success and null branches without
* depending on the host machine having a configured global git identity.
*
* Returns null on:
* - system git not available (when not using injected exec)
* - either config value missing / empty / whitespace-only
* - git exec throwing (e.g. key not set, which git reports as exit 1)
* The catch block intentionally swallows errors: "no identity" is a normal
* state, not an operational failure, and the caller treats null as "fall
* through to the next step in the chain".
*/
export async function getSystemAuthor(
injectedExec?: RunGitExec,
): Promise<{ name: string; email: string } | null> {
if (!injectedExec && !(await isSystemGitAvailable())) return null;
const exec: RunGitExec = injectedExec ?? ((args) => runGit(args, { timeoutMs: 5000 }));
try {
const nameResult = await exec(['config', '--get', 'user.name']);
const emailResult = await exec(['config', '--get', 'user.email']);
const name = nameResult.stdout.trim();
const email = emailResult.stdout.trim();
if (!name || !email) return null;
return { name, email };
} catch {
return null;
}
}
/**
* Build the GIT_SSH_COMMAND env value for an SSH key file. Used by the
* engine before invoking sysClone/Fetch/Pull/Push with auth.kind === 'ssh'.
*/
export function buildSshCommand(privateKeyPath: string): string {
// -i: identity file
// -o IdentitiesOnly=yes: don't try ssh-agent identities (avoid prompting)
// -o StrictHostKeyChecking=accept-new: trust on first use, verify thereafter
// -o BatchMode=yes: never prompt; fail fast on missing creds
return [
'ssh',
'-i',
JSON.stringify(privateKeyPath),
'-o',
'IdentitiesOnly=yes',
'-o',
'StrictHostKeyChecking=accept-new',
'-o',
'BatchMode=yes',
].join(' ');
}