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

90 lines
2.7 KiB
TypeScript

// apps/desktop/git/repo-detector.ts
//
// Discover whether a .op file lives inside a git repository, and if so,
// in which mode (single-file or folder). Returns a discriminated union
// the engine can match against without follow-up filesystem checks.
import { dirname, basename, resolve } from 'node:path';
import { stat } from 'node:fs/promises';
/**
* Result of a successful detection. The shape is the same for both modes
* so the engine can pass it directly to `openRepo` regardless of mode.
*/
export interface RepoDetectionFound {
mode: 'single-file' | 'folder';
/** worktree root (parent of the .op file in single-file mode; repo root in folder mode) */
rootPath: string;
/** absolute path to the gitdir */
gitdir: string;
}
export type RepoDetection = RepoDetectionFound | { mode: 'none' };
/**
* Walk up from the given .op file looking for a tracked repository.
*
* Order of checks (single-file wins per spec):
* 1. <dirname(filePath)>/.op-history/<basename(filePath)>.git/HEAD exists
* → single-file mode
* 2. Walk up parent dirs looking for any /.git/HEAD → folder mode
* 3. Otherwise → none
*
* The function never throws on missing files; only on filesystem errors that
* indicate something deeper is wrong (permissions, broken symlinks). Those
* propagate as standard Node errors and are NOT wrapped in GitError — the
* engine layer is responsible for translation.
*/
export async function detectRepo(filePath: string): Promise<RepoDetection> {
const absFile = resolve(filePath);
const parentDir = dirname(absFile);
const baseName = basename(absFile);
// 1. Single-file mode check.
const singleGitdir = resolve(parentDir, '.op-history', `${baseName}.git`);
if (await pathExists(resolve(singleGitdir, 'HEAD'))) {
return {
mode: 'single-file',
rootPath: parentDir,
gitdir: singleGitdir,
};
}
// 2. Walk up parents looking for a .git directory.
let current = parentDir;
while (true) {
const candidate = resolve(current, '.git');
if (await pathExists(resolve(candidate, 'HEAD'))) {
return {
mode: 'folder',
rootPath: current,
gitdir: candidate,
};
}
const parent = dirname(current);
if (parent === current) {
// Reached filesystem root.
break;
}
current = parent;
}
// 3. No repo found.
return { mode: 'none' };
}
/**
* Returns true if `path` exists (file or directory). Returns false on ENOENT.
* Re-throws other errors (permission denied, etc.) so we don't silently
* misbehave.
*/
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw err;
}
}