diff --git a/apps/desktop/electron/git-repo-scan.cjs b/apps/desktop/electron/git-repo-scan.cjs new file mode 100644 index 000000000000..7b56eed40c25 --- /dev/null +++ b/apps/desktop/electron/git-repo-scan.cjs @@ -0,0 +1,98 @@ +'use strict' + +// Repo-first discovery: walk bounded roots for git repos using only Node's `fs` +// — no native addon, so it just works for anyone who pulls main (no +// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git` +// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the +// first scan stays fast. Results are cached by the backend after the first run. + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const fsp = fs.promises + +// Shallow on purpose: real projects live a few levels under home +// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always +// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos +// you actually use but keep deeper still surface via session-derived discovery, +// so this only prunes noise, never repos with history. +const DEFAULT_MAX_DEPTH = 3 +const MAX_CONCURRENCY = 32 + +// Big trees that are never themselves repos and would waste the walk. Anything +// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this +// only needs the non-hidden heavyweights. +const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv']) + +async function mapLimit(items, limit, fn) { + let cursor = 0 + + async function worker() { + while (cursor < items.length) { + const index = cursor + cursor += 1 + await fn(items[index]) + } + } + + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) +} + +/** + * Scan `roots` (default: the home dir) for git repositories. Returns deduped + * `{ root, label }` entries. `options.maxDepth` caps recursion (default 3). + */ +async function scanGitRepos(roots, options = {}) { + const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH + const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()] + const found = new Map() + + async function walk(dir, depth) { + if (depth > maxDepth) { + return + } + + let entries + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } catch { + return // unreadable / permission denied + } + + // A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git` + // FILE is a linked worktree or submodule — those belong to their parent + // repo as lanes, not as separate projects, so we don't list them (and we + // keep descending in case a real repo sits deeper). This is what kills the + // worktree/eval-repo duplicate explosion. + if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) { + const root = dir.replace(/[/\\]+$/, '') + found.set(root, path.basename(root) || root) + + return + } + + const subdirs = [] + for (const entry of entries) { + // Real directories only (skip symlinks to avoid loops), no hidden dirs, no + // known heavy trees. + if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) { + continue + } + + subdirs.push(path.join(dir, entry.name)) + } + + await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1)) + } + + await mapLimit( + searchRoots.map(root => String(root || '').trim()).filter(Boolean), + MAX_CONCURRENCY, + root => walk(root, 0) + ) + + return [...found.entries()].map(([root, label]) => ({ label, root })) +} + +module.exports = { scanGitRepos } diff --git a/apps/desktop/electron/git-review-ops.cjs b/apps/desktop/electron/git-review-ops.cjs new file mode 100644 index 000000000000..19b4aecf92d9 --- /dev/null +++ b/apps/desktop/electron/git-review-ops.cjs @@ -0,0 +1,679 @@ +'use strict' + +// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git` +// (a maintained wrapper around the system git binary — same git the rest of the +// app shells to, no native build) so we read structured status()/diffSummary() +// results instead of hand-parsing porcelain. Reads degrade to null/empty on a +// non-repo / remote backend; mutations reject so the renderer can toast. + +const { execFile } = require('node:child_process') +const fs = require('node:fs/promises') +const path = require('node:path') + +const simpleGit = require('simple-git') + +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000 +const COMMIT_CONTEXT_UNTRACKED_MAX = 80 +const UNTRACKED_LINE_COUNT_CONCURRENCY = 16 +const UNTRACKED_LINE_COUNT_MAX_BYTES = 1024 * 1024 + +// GUI-launched Electron apps on macOS inherit only a minimal PATH (no +// /opt/homebrew/bin or /usr/local/bin), so `gh` — and the `git` gh shells out +// to — aren't found. Augment PATH with the resolved gh dir + the common +// package-manager bins so gh runs the same way it does in a terminal. +function ghEnv(ghBin) { + const extra = [ghBin ? path.dirname(ghBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'].filter( + dir => dir && dir !== '.' + ) + + return { ...process.env, PATH: [...extra, process.env.PATH].filter(Boolean).join(path.delimiter) } +} + +// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on +// availability/auth without a throw. gh missing/unauthed → ok:false. +function runGh(args, cwd, ghBin) { + return new Promise(resolve => { + execFile( + ghBin || 'gh', + args, + { cwd, env: ghEnv(ghBin), windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, + (err, stdout) => resolve({ ok: !err, stdout: String(stdout || '') }) + ) + }) +} + +function gitFor(cwd, gitBin) { + return simpleGit({ baseDir: cwd, binary: gitBin || 'git', maxConcurrentProcesses: 4, trimmed: false }) +} + +// simple-git reports renames as `old => new` (and `dir/{old => new}/f`); resolve +// to the NEW path so the row addresses the real file for diff/stage. +function resolveRenamePath(raw) { + const path = String(raw || '').trim() + + if (!path.includes(' => ')) { + return path + } + + const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/) + + if (brace) { + const [, prefix, , to, suffix] = brace + + return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/') + } + + return path.split(' => ').pop().trim() +} + +// DiffResult.files → Map (binary files carry no line +// delta). +function countsByPath(summary) { + const map = new Map() + + for (const file of summary.files) { + map.set(resolveRenamePath(file.file), { + added: file.binary ? 0 : file.insertions, + removed: file.binary ? 0 : file.deletions + }) + } + + return map +} + +// Untracked files don't appear in diffSummary(); count insertions from disk so +// the review tree can show +N for new files (matches an all-add diff view). +// Insertions = line count: newline bytes, plus one for a final unterminated +// line. Binary (NUL byte) → 0, mirroring git numstat's "-". +async function untrackedInsertions(cwd, relPath) { + try { + const fullPath = path.join(cwd, relPath) + const stat = await fs.stat(fullPath) + + if (!stat.isFile() || stat.size > UNTRACKED_LINE_COUNT_MAX_BYTES) { + return 0 + } + + const buf = await fs.readFile(fullPath) + + if (buf.includes(0)) { + return 0 + } + + let lines = 0 + + for (const byte of buf) { + if (byte === 10) { + lines++ + } + } + + return buf.length > 0 && buf[buf.length - 1] !== 10 ? lines + 1 : lines + } catch { + return 0 + } +} + +function capText(text, maxChars, label = 'truncated') { + const value = String(text || '') + + if (value.length <= maxChars) { + return value + } + + return `${value.slice(0, maxChars)}\n# ${label}: ${value.length - maxChars} chars omitted\n` +} + +async function fillUntrackedCounts(cwd, files) { + const pending = files.filter(file => file.status === '?' && file.added === 0 && file.removed === 0) + + for (let i = 0; i < pending.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { + await Promise.all( + pending.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(async file => { + file.added = await untrackedInsertions(cwd, file.path) + }) + ) + } +} + +// Resolve the base ref for "all branch changes": merge-base with the remote +// default branch (origin/HEAD), falling back to common trunk names. +async function branchBase(git) { + const candidates = [] + + try { + const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim() + + if (head) { + candidates.push(head) + } + } catch { + // No origin/HEAD configured. + } + + candidates.push('origin/main', 'origin/master', 'main', 'master') + + for (const ref of candidates) { + try { + const base = (await git.raw(['merge-base', 'HEAD', ref])).trim() + + if (base) { + return base + } + } catch { + // Ref doesn't exist; try the next candidate. + } + } + + return null +} + +// Resolve the repo's default branch NAME ("main" / "master" / …), preferring +// the remote's HEAD, then common local trunk names. Null when none is found +// (e.g. a fresh repo with only a feature branch). Used to offer "branch off the +// trunk" regardless of which branch you're currently on. +async function defaultBranchName(git) { + try { + const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim() + + // "origin/main" → "main"; skip the bare "origin/HEAD" placeholder. + if (head && head !== 'origin/HEAD') { + return head.replace(/^origin\//, '') + } + } catch { + // No origin/HEAD configured. + } + + // Prefer a local trunk, then a remote-only one (returns the clean name either + // way) so "branch off main" works even before main is checked out locally. + for (const ref of ['refs/heads/main', 'refs/heads/master', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) { + try { + await git.raw(['rev-parse', '--verify', '--quiet', ref]) + + return ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '') + } catch { + // Ref doesn't exist; try the next candidate. + } + } + + return null +} + +// A status file's single-letter classification, preferring the staged (index) +// code over the worktree code; untracked wins (simple-git marks both '?'). +function statusLetter(file) { + if (file.index === '?' || file.working_dir === '?') { + return '?' + } + + const code = file.index && file.index !== ' ' ? file.index : file.working_dir + + return (code || 'M').toUpperCase() +} + +const isStaged = file => Boolean(file.index && file.index !== ' ' && file.index !== '?') + +async function reviewList(repoPath, scope, baseRef, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review list' }) + } catch { + return { files: [], base: null } + } + + const git = gitFor(cwd, gitBin) + + try { + if (scope === 'branch' || scope === 'lastTurn') { + const base = scope === 'branch' ? await branchBase(git) : baseRef + + if (!base) { + return { files: [], base: null } + } + + const range = scope === 'branch' ? `${base}...HEAD` : base + const summary = await git.diffSummary([range]) + const files = summary.files.map(file => ({ + path: resolveRenamePath(file.file), + added: file.binary ? 0 : file.insertions, + removed: file.binary ? 0 : file.deletions, + status: 'M', + staged: false + })) + + // "Last turn" also surfaces files created since the baseline (untracked). + if (scope === 'lastTurn') { + const status = await git.status() + + for (const path of status.not_added) { + if (!files.some(f => f.path === path)) { + files.push({ path, added: 0, removed: 0, status: '?', staged: false }) + } + } + } + + files.sort((a, b) => a.path.localeCompare(b.path)) + await fillUntrackedCounts(cwd, files) + + return { files, base } + } + + // Default: uncommitted (staged + unstaged + untracked), one row per path. + const [status, staged, unstaged] = await Promise.all([ + git.status(), + git.diffSummary(['--cached']), + git.diffSummary([]) + ]) + const stagedCounts = countsByPath(staged) + const unstagedCounts = countsByPath(unstaged) + + const files = status.files.map(file => { + const filePath = resolveRenamePath(file.path) + const sc = stagedCounts.get(filePath) || { added: 0, removed: 0 } + const uc = unstagedCounts.get(filePath) || { added: 0, removed: 0 } + + return { + path: filePath, + added: sc.added + uc.added, + removed: sc.removed + uc.removed, + status: statusLetter(file), + staged: isStaged(file) + } + }) + + files.sort((a, b) => a.path.localeCompare(b.path)) + await fillUntrackedCounts(cwd, files) + + return { files, base: null } + } catch { + return { files: [], base: null } + } +} + +async function reviewDiff(repoPath, filePath, scope, baseRef, staged, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review diff' }) + } catch { + return '' + } + + const git = gitFor(cwd, gitBin) + const safe = args => git.diff(args).catch(() => '') + + if (scope === 'branch') { + const base = await branchBase(git) + + return base ? safe([`${base}...HEAD`, '--', filePath]) : '' + } + + if (scope === 'lastTurn') { + return baseRef ? safe([baseRef, '--', filePath]) : '' + } + + if (staged) { + return safe(['--cached', '--', filePath]) + } + + const worktree = await safe(['--', filePath]) + + if (worktree.trim()) { + return worktree + } + + // Untracked file: no worktree diff exists, so synthesize an all-add diff via + // --no-index (exits non-zero by design when files differ, so go around + // simple-git's reject-on-nonzero with a raw execFile). + return new Promise(resolve => { + execFile( + gitBin || 'git', + ['diff', '--no-index', '--', '/dev/null', filePath], + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 }, + (_err, stdout) => resolve(String(stdout || '')) + ) + }) +} + +// Working-tree-vs-HEAD diff for ONE file — the "what changed since the last +// commit" view used by the file preview. Unlike reviewDiff this never synthesizes +// a full-add for a clean tracked file (so a pristine file shows no diff); it only +// all-adds a genuinely untracked file. +async function fileDiffVsHead(repoPath, filePath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'File diff' }) + } catch { + return '' + } + + const git = gitFor(cwd, gitBin) + const head = await git.diff(['HEAD', '--', filePath]).catch(() => '') + + if (head.trim()) { + return head + } + + // No tracked changes vs HEAD. Only synthesize an all-add diff for a file git + // doesn't know yet; a clean tracked file must return empty. + const status = await git.raw(['status', '--porcelain', '--', filePath]).catch(() => '') + + if (!status.trim().startsWith('??')) { + return '' + } + + return new Promise(resolve => { + execFile( + gitBin || 'git', + ['diff', '--no-index', '--', '/dev/null', filePath], + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 }, + (_err, stdout) => resolve(String(stdout || '')) + ) + }) +} + +async function reviewStage(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review stage' }) + + await gitFor(cwd, gitBin).raw(filePath ? ['add', '--', filePath] : ['add', '-A']) + + return { ok: true } +} + +async function reviewUnstage(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review unstage' }) + + await gitFor(cwd, gitBin).raw(filePath ? ['reset', '-q', 'HEAD', '--', filePath] : ['reset', '-q', 'HEAD']) + + return { ok: true } +} + +// Discard changes back to the committed state. Destructive — the renderer +// confirms first. Restores tracked files and removes untracked ones. +async function reviewRevert(repoPath, filePath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review revert' }) + const git = gitFor(cwd, gitBin) + + if (filePath) { + await git.raw(['checkout', 'HEAD', '--', filePath]).catch(() => undefined) + await git.raw(['clean', '-fd', '--', filePath]).catch(() => undefined) + } else { + await git.raw(['checkout', 'HEAD', '--', '.']).catch(() => undefined) + await git.raw(['clean', '-fd']).catch(() => undefined) + } + + return { ok: true } +} + +// Resolve a ref to a commit sha (captures the turn baseline for "Last turn"). +async function reviewRevParse(repoPath, ref, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review rev-parse' }) + } catch { + return null + } + + try { + return (await gitFor(cwd, gitBin).revparse([ref || 'HEAD'])).trim() || null + } catch { + return null + } +} + +// Commit the working tree. Mirrors VS Code: if nothing is staged, stage +// everything first ("commit all"), then commit. Optionally push afterward, +// setting upstream on the first push. +async function reviewCommit(repoPath, message, push, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit' }) + const git = gitFor(cwd, gitBin) + const status = await git.status() + + if (status.staged.length === 0) { + await git.raw(['add', '-A']) + } + + await git.commit(message) + + if (push) { + const fresh = await git.status() + + if (fresh.tracking) { + await git.push() + } else if (fresh.current) { + await git.raw(['push', '-u', 'origin', fresh.current]) + } + } + + return { ok: true } +} + +// Gather the context the model needs to draft a commit message: the diff of +// what *will* be committed (staged when anything is staged, else everything +// vs HEAD — mirroring reviewCommit's "stage all when nothing staged" rule), +// the names of untracked files (which carry no diff), and recent commit +// subjects for style. Diff is capped so the payload stays bounded. Reads only. +async function reviewCommitContext(repoPath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit context' }) + } catch { + return { diff: '', recent: '' } + } + + const git = gitFor(cwd, gitBin) + const safe = args => git.diff(args).catch(() => '') + + let status + try { + status = await git.status() + } catch { + return { diff: '', recent: '' } + } + + // What will land: staged changes if any, otherwise all tracked changes vs HEAD. + let diff = capText( + status.staged.length > 0 ? await safe(['--cached']) : await safe(['HEAD']), + COMMIT_CONTEXT_DIFF_MAX_CHARS, + 'diff truncated for commit-message generation' + ) + + // Untracked files have no diff — list them so new files aren't invisible. + const untracked = status.not_added || [] + if (untracked.length > 0) { + const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX) + const omitted = untracked.length - visible.length + const note = + `\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` + + (omitted > 0 ? `# ... ${omitted} more omitted\n` : '') + + diff = diff ? `${diff}${note}` : note + } + + const recent = await git.raw(['log', '-n', '10', '--pretty=format:%s']).catch(() => '') + + return { diff: diff || '', recent: String(recent || '').trim() } +} + +async function reviewPush(repoPath, gitBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review push' }) + const git = gitFor(cwd, gitBin) + const status = await git.status() + + if (status.tracking) { + await git.push() + } else if (status.current) { + await git.raw(['push', '-u', 'origin', status.current]) + } + + return { ok: true } +} + +// gh availability + auth + whether this branch already has a PR. Reads only; +// drives the PR button's enabled/label state. `ghReady` is false when gh is +// missing OR not authenticated — either way the PR action can't run. +async function reviewShipInfo(repoPath, ghBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review ship info' }) + } catch { + return { ghReady: false, pr: null } + } + + const auth = await runGh(['auth', 'status'], cwd, ghBin) + + if (!auth.ok) { + return { ghReady: false, pr: null } + } + + const view = await runGh(['pr', 'view', '--json', 'url,state,number'], cwd, ghBin) + + if (!view.ok) { + // gh exits non-zero when no PR exists for the branch — that's not an error. + return { ghReady: true, pr: null } + } + + try { + const pr = JSON.parse(view.stdout) + + return { ghReady: true, pr: pr && pr.url ? { url: pr.url, state: pr.state, number: pr.number } : null } + } catch { + return { ghReady: true, pr: null } + } +} + +// Create a PR for the current branch (pushing first so gh has a remote ref), +// letting gh fill title/body from the commits. Returns the new PR url. +async function reviewCreatePr(repoPath, gitBin, ghBin) { + const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review create PR' }) + + await reviewPush(repoPath, gitBin).catch(() => undefined) + + const created = await runGh(['pr', 'create', '--fill'], cwd, ghBin) + + if (!created.ok) { + throw new Error('gh pr create failed (is gh installed and authenticated?)') + } + + const url = created.stdout.trim().split('\n').filter(Boolean).pop() || '' + + return { url } +} + +// Compact working-tree status for the composer coding rail: branch, ahead/behind, +// per-state change counts, +/- vs HEAD, and a capped changed-file list. +async function repoStatus(repoPath, gitBin) { + let cwd + + try { + cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Repo status' }) + } catch { + return null + } + + // Session cwds can point at a deleted worktree for a moment (or forever in a + // stale row). simple-git throws at construction time on a missing baseDir, so + // fail soft and hide the coding rail instead of spamming IPC handler errors. + try { + const stat = await fs.stat(cwd) + if (!stat.isDirectory()) { + return null + } + } catch { + return null + } + + let git + try { + git = gitFor(cwd, gitBin) + } catch { + return null + } + let status + + try { + status = await git.status() + } catch { + // Not a repo / git unavailable / remote backend. + return null + } + + const detached = typeof status.detached === 'boolean' ? status.detached : !status.current + const files = status.files.map(file => ({ + path: file.path, + staged: isStaged(file), + unstaged: Boolean(file.working_dir && file.working_dir !== ' ' && file.working_dir !== '?'), + untracked: file.index === '?' || file.working_dir === '?', + conflicted: file.index === 'U' || file.working_dir === 'U' + })) + + const result = { + branch: detached ? null : status.current || null, + defaultBranch: await defaultBranchName(git), + detached, + ahead: status.ahead || 0, + behind: status.behind || 0, + staged: files.filter(f => f.staged).length, + unstaged: files.filter(f => f.unstaged).length, + untracked: status.not_added.length, + conflicted: status.conflicted.length, + changed: files.length, + added: 0, + removed: 0, + files: files.slice(0, 200) + } + + // +/- vs HEAD (staged + unstaged tracked changes). No HEAD yet → leave 0. + try { + const summary = await git.diffSummary(['HEAD']) + result.added = summary.insertions + result.removed = summary.deletions + } catch { + // No commits yet. + } + + // `git diff HEAD` ignores untracked files, so a turn that only creates new + // files (the common case — a fresh module, a demo dir) showed +0 in the rail + // while the review pane counted them. Fold untracked insertions into `added` + // so the rail matches reality. Bounded (size cap + concurrency) like the + // review tree; only the capped file slice is counted so a huge untracked tree + // can't stall the probe. + try { + const untracked = status.not_added.slice(0, 500) + for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { + const batch = await Promise.all( + untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path)) + ) + result.added += batch.reduce((sum, n) => sum + n, 0) + } + } catch { + // Best-effort: a probe failure just leaves untracked lines uncounted. + } + + return result +} + +module.exports = { + branchBase, + fileDiffVsHead, + repoStatus, + resolveRenamePath, + reviewCommit, + reviewCommitContext, + reviewCreatePr, + reviewDiff, + reviewList, + reviewPush, + reviewRevParse, + reviewRevert, + reviewShipInfo, + reviewStage, + reviewUnstage +} diff --git a/apps/desktop/electron/git-review-ops.test.cjs b/apps/desktop/electron/git-review-ops.test.cjs new file mode 100644 index 000000000000..fdddd13df78b --- /dev/null +++ b/apps/desktop/electron/git-review-ops.test.cjs @@ -0,0 +1,22 @@ +'use strict' + +const assert = require('node:assert/strict') +const test = require('node:test') + +const { resolveRenamePath } = require('./git-review-ops.cjs') + +test('resolveRenamePath: plain path is unchanged', () => { + assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts') +}) + +test('resolveRenamePath: simple rename resolves to the new path', () => { + assert.equal(resolveRenamePath('old.ts => new.ts'), 'new.ts') +}) + +test('resolveRenamePath: brace rename resolves to the new path', () => { + assert.equal(resolveRenamePath('src/{old => new}/file.ts'), 'src/new/file.ts') +}) + +test('resolveRenamePath: brace rename collapsing a segment', () => { + assert.equal(resolveRenamePath('src/{lib => }/file.ts'), 'src/file.ts') +}) diff --git a/apps/desktop/electron/git-worktree-ops.cjs b/apps/desktop/electron/git-worktree-ops.cjs new file mode 100644 index 000000000000..98373d905638 --- /dev/null +++ b/apps/desktop/electron/git-worktree-ops.cjs @@ -0,0 +1,291 @@ +'use strict' + +// Git-driven worktree operations for the desktop "Start work" flow: spin up a +// fresh worktree the lightest way (`git worktree add -b`), list real worktrees, +// and remove them. Git is the source of truth; the renderer just drives these. + +const path = require('node:path') +const fs = require('node:fs') +const { execFile } = require('node:child_process') + +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +function runGit(gitBin, args, cwd) { + return new Promise((resolve, reject) => { + execFile( + gitBin, + args, + { cwd, windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, + (err, stdout, stderr) => { + if (err) { + err.stderr = String(stderr || '') + reject(err) + + return + } + + resolve(String(stdout || '')) + } + ) + }) +} + +// Parse `git worktree list --porcelain`. The first record is the main worktree. +function parseWorktrees(out) { + const trees = [] + let cur = null + + for (const line of out.split('\n')) { + if (line.startsWith('worktree ')) { + if (cur) { + trees.push(cur) + } + + cur = { path: line.slice(9).trim(), branch: null, detached: false, bare: false, locked: false } + } else if (!cur) { + continue + } else if (line.startsWith('branch ')) { + cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '') + } else if (line === 'detached') { + cur.detached = true + } else if (line === 'bare') { + cur.bare = true + } else if (line.startsWith('locked')) { + cur.locked = true + } + } + + if (cur) { + trees.push(cur) + } + + return trees +} + +async function listWorktrees(repoPath, gitBin) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree list' }) + } catch { + return [] + } + + try { + const out = await runGit(gitBin, ['worktree', 'list', '--porcelain'], resolved) + + return parseWorktrees(out).map((tree, index) => ({ + path: tree.path, + branch: tree.branch, + isMain: index === 0, + detached: tree.detached, + locked: tree.locked + })) + } catch { + return [] + } +} + +// A git-ref-safe branch name (spaces → "-", drop forbidden chars, trim edges), +// or "" when nothing usable remains. Mirrors the renderer's `gitRef`, so a bad +// value can't reach `git` no matter the caller (the GUI also enforces live). +function sanitizeBranch(name) { + return String(name || '') + .replace(/\s+/g, '-') + .replace(/[^\w./-]/g, '') + .replace(/-{2,}/g, '-') + .replace(/\/{2,}/g, '/') + .replace(/\.{2,}/g, '.') + .replace(/^[-./]+|[-./]+$/g, '') +} + +function slugify(name) { + const slug = String(name || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + .replace(/-+$/g, '') + + return slug || 'work' +} + +// A brand-new project folder isn't a git repo — and a freshly-init'd one has no +// commit to branch from — so `git worktree add` would fail. Make the dir a repo +// with a root commit on the user's behalf so worktrees "just work". No-op for a +// repo that already has commits; never touches the user's files (the seed commit +// is `--allow-empty`), and never inits a dir that already lives inside a repo. +async function ensureGitRepo(gitBin, dir) { + let needsRoot = false + + try { + const inside = (await runGit(gitBin, ['rev-parse', '--is-inside-work-tree'], dir)).trim() + + if (inside !== 'true') { + await runGit(gitBin, ['init'], dir) + needsRoot = true + } else { + // Repo exists; a worktree still needs a HEAD to branch from. + try { + await runGit(gitBin, ['rev-parse', '--verify', 'HEAD'], dir) + } catch { + needsRoot = true + } + } + } catch { + await runGit(gitBin, ['init'], dir) + needsRoot = true + } + + if (needsRoot) { + // Inline identity so the seed commit lands even with no global git config. + await runGit( + gitBin, + ['-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'Initial commit'], + dir + ) + } +} + +// Resolve the repo's MAIN worktree root, so `.worktrees/` always nests under the +// primary checkout even when called from a linked worktree. +async function mainRoot(gitBin, cwd) { + const list = await listWorktrees(cwd, gitBin) + const main = list.find(tree => tree.isMain) + + return main ? main.path : cwd +} + +function uniqueDir(base) { + let dir = base + let n = 1 + + while (fs.existsSync(dir)) { + n += 1 + dir = `${base}-${n}` + } + + return dir +} + +async function addWorktree(repoPath, options, gitBin) { + const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree add' }) + // A new project's folder may not be a git repo yet — init it (with a root + // commit) so the worktree has something to branch from. + await ensureGitRepo(gitBin, resolved) + const root = await mainRoot(gitBin, resolved) + const opts = options || {} + + // "Convert an existing branch into a worktree": check the branch out into a + // fresh worktree dir as-is (no `-b`, no new branch). Dir is named off the + // branch slug so it reads like the branch it carries. + if (opts.existingBranch) { + const existing = sanitizeBranch(opts.existingBranch) + + if (!existing) { + throw new Error('Branch name is required.') + } + + const dir = uniqueDir(path.join(root, '.worktrees', slugify(existing))) + await runGit(gitBin, ['worktree', 'add', dir, existing], root) + + return { path: dir, branch: existing, repoRoot: root } + } + + const slug = slugify(opts.name || `work-${Date.now().toString(36)}`) + const branch = sanitizeBranch(opts.branch) || `hermes/${slug}` + const dir = uniqueDir(path.join(root, '.worktrees', slug)) + + const args = ['worktree', 'add', '-b', branch, dir] + + if (opts.base) { + args.push(String(opts.base)) + } + + try { + await runGit(gitBin, args, root) + } catch (err) { + // Branch name may already exist — retry checking out the existing branch + // into a fresh worktree dir instead of failing the whole flow. + if (/already exists/i.test(err.stderr || '')) { + await runGit(gitBin, ['worktree', 'add', dir, branch], root) + } else { + throw err + } + } + + return { path: dir, branch, repoRoot: root } +} + +async function removeWorktree(repoPath, worktreePath, options, gitBin) { + const resolvedRepo = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree remove (repo)' }) + const resolvedTree = resolveRequestedPathForIpc(worktreePath, { purpose: 'Worktree remove (tree)' }) + const root = await mainRoot(gitBin, resolvedRepo) + const args = ['worktree', 'remove'] + + if (options && options.force) { + args.push('--force') + } + + args.push(resolvedTree) + await runGit(gitBin, args, root) + + return { removed: resolvedTree } +} + +// List local branches for the "convert a branch into a worktree" picker, most +// recently committed first. Each carries whether it's already checked out in a +// worktree and, when checked out, that worktree's path. Empty on a non-repo / +// remote backend where the probe can't run. +async function listBranches(repoPath, gitBin) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch list' }) + } catch { + return [] + } + + try { + const out = await runGit( + gitBin, + ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], + resolved + ) + const trees = await listWorktrees(resolved, gitBin) + const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path])) + + return out + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(name => ({ name, checkedOut: pathByBranch.has(name), worktreePath: pathByBranch.get(name) || null })) + } catch { + return [] + } +} + +async function switchBranch(repoPath, branch, gitBin) { + const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch switch' }) + const target = sanitizeBranch(branch) + + if (!target) { + throw new Error('Branch name is required.') + } + + await runGit(gitBin, ['switch', target], resolved) + + return { branch: target } +} + +module.exports = { + addWorktree, + ensureGitRepo, + listBranches, + listWorktrees, + parseWorktrees, + removeWorktree, + sanitizeBranch, + switchBranch +} diff --git a/apps/desktop/electron/git-worktree-ops.test.cjs b/apps/desktop/electron/git-worktree-ops.test.cjs new file mode 100644 index 000000000000..ec6a96c9621d --- /dev/null +++ b/apps/desktop/electron/git-worktree-ops.test.cjs @@ -0,0 +1,172 @@ +'use strict' + +const assert = require('node:assert/strict') +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') + +const { + addWorktree, + ensureGitRepo, + listBranches, + parseWorktrees, + sanitizeBranch, + switchBranch +} = require('./git-worktree-ops.cjs') + +test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => { + assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes') + assert.equal(sanitizeBranch('feat/cool thing'), 'feat/cool-thing') + assert.equal(sanitizeBranch(' wip~^:? '), 'wip') + assert.equal(sanitizeBranch('///'), '') +}) + +test('parseWorktrees: main checkout + linked worktree', () => { + const out = [ + 'worktree /repo', + 'HEAD abc123', + 'branch refs/heads/main', + '', + 'worktree /repo/.worktrees/feat', + 'HEAD def456', + 'branch refs/heads/hermes/feat', + '' + ].join('\n') + + const trees = parseWorktrees(out) + + assert.equal(trees.length, 2) + assert.equal(trees[0].path, '/repo') + assert.equal(trees[0].branch, 'main') + assert.equal(trees[1].path, '/repo/.worktrees/feat') + assert.equal(trees[1].branch, 'hermes/feat') +}) + +test('parseWorktrees: detached + locked flags', () => { + const out = ['worktree /repo/wt', 'HEAD abc', 'detached', 'locked reason', ''].join('\n') + const trees = parseWorktrees(out) + + assert.equal(trees.length, 1) + assert.equal(trees[0].detached, true) + assert.equal(trees[0].locked, true) + assert.equal(trees[0].branch, null) +}) + +test('parseWorktrees: empty input', () => { + assert.deepEqual(parseWorktrees(''), []) +}) + +test('ensureGitRepo: inits a plain dir with a root commit so worktrees branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-wt-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + assert.match(git('rev-parse', '--verify', 'HEAD'), /^[0-9a-f]{7,}$/) + + // The whole point: a worktree can now branch off the seeded root commit. + execFileSync('git', ['worktree', 'add', '-b', 'wt', path.join(dir, '.worktrees', 'wt')], { cwd: dir }) + assert.ok(fs.existsSync(path.join(dir, '.worktrees', 'wt'))) + + // Idempotent: an already-committed repo gets no extra commit. + await ensureGitRepo('git', dir) + assert.equal(git('rev-list', '--count', 'HEAD'), '1') + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('switchBranch: switches a normal checkout branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-switch-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + + await switchBranch(dir, 'feature', 'git') + + assert.equal(git('branch', '--show-current'), 'feature') + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: lists locals and flags the checked-out branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-')) + + try { + await ensureGitRepo('git', dir) + const current = execFileSync('git', ['branch', '--show-current'], { cwd: dir }).toString().trim() + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + + const branches = await listBranches(dir, 'git') + const names = branches.map(b => b.name).sort() + + assert.deepEqual(names, [current, 'feature'].sort()) + // The repo's own checkout is flagged; the unused branch is convertible. + assert.equal(branches.find(b => b.name === current).checkedOut, true) + assert.equal(fs.realpathSync(branches.find(b => b.name === current).worktreePath), fs.realpathSync(dir)) + assert.equal(branches.find(b => b.name === 'feature').checkedOut, false) + assert.equal(branches.find(b => b.name === 'feature').worktreePath, null) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: a branch claimed by a worktree is flagged checked out', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-wt-')) + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + // addWorktree converts the existing "feature" branch into a worktree. + const result = await addWorktree(dir, { existingBranch: 'feature' }, 'git') + + assert.equal(result.branch, 'feature') + assert.ok(fs.existsSync(result.path)) + + const branches = await listBranches(dir, 'git') + + assert.equal(branches.find(b => b.name === 'feature').checkedOut, true) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBranches: empty on a non-repo path', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-nonrepo-')) + + try { + assert.deepEqual(await listBranches(dir, 'git'), []) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('addWorktree: existingBranch checks the branch out without a new branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'cool/feature'], { cwd: dir }) + + const before = git('branch', '--list').split('\n').length + const result = await addWorktree(dir, { existingBranch: 'cool/feature' }, 'git') + + // No new branch was created — only the existing one is checked out. + assert.equal(git('branch', '--list').split('\n').length, before) + assert.equal(result.branch, 'cool/feature') + // Dir is named off the branch slug, nested under the main repo's .worktrees. + assert.match(result.path, /[/\\]\.worktrees[/\\]cool-feature/) + assert.equal( + execFileSync('git', ['branch', '--show-current'], { cwd: result.path }).toString().trim(), + 'cool/feature' + ) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/git-worktrees.cjs b/apps/desktop/electron/git-worktrees.cjs deleted file mode 100644 index 570397b2c952..000000000000 --- a/apps/desktop/electron/git-worktrees.cjs +++ /dev/null @@ -1,174 +0,0 @@ -'use strict' - -// Resolve git-worktree relationships for a set of session cwds, reading git's -// on-disk metadata directly (no `git` spawn per path): -// -// - A normal checkout has a `.git` DIRECTORY at its root → it's the main -// worktree; its repo root IS that directory's parent. -// - A linked worktree has a `.git` FILE: `gitdir: /.git/worktrees/`. -// That admin dir's `commondir` points back at the shared `/.git`, whose -// parent is the main repo root. -// -// Grouping by repoRoot therefore clusters a repo's main checkout with all of its -// linked worktrees, regardless of how the worktree directories are named. The -// branch (read from the worktree's own HEAD) gives each worktree a meaningful -// label. - -const fs = require('node:fs') -const path = require('node:path') -const { resolveRequestedPathForIpc } = require('./hardening.cjs') - -// Walk up from `start` to the nearest ancestor that carries a `.git` entry -// (file for a linked worktree, dir for the main checkout). Capped so a stray -// path can't loop forever. -function findGitHost(start, fsImpl) { - let dir = start - - for (let i = 0; i < 64; i += 1) { - const dotgit = path.join(dir, '.git') - - try { - if (fsImpl.existsSync(dotgit)) { - return dir - } - } catch { - return null - } - - const parent = path.dirname(dir) - - if (parent === dir) { - return null - } - - dir = parent - } - - return null -} - -function readBranch(gitDir, fsImpl) { - try { - const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim() - const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/) - - if (ref) { - return ref[1] - } - - // Detached HEAD: surface a short sha so the worktree still gets a label. - return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null - } catch { - return null - } -} - -// Given the directory that owns the `.git` entry, resolve its worktree identity. -function resolveFromHost(host, fsImpl) { - const dotgit = path.join(host, '.git') - let stat - - try { - stat = fsImpl.statSync(dotgit) - } catch { - return null - } - - if (stat.isDirectory()) { - return { - repoRoot: host, - worktreeRoot: host, - isMainWorktree: true, - branch: readBranch(dotgit, fsImpl) - } - } - - // Linked worktree: `.git` is a file pointing at the admin dir. - let contents - - try { - contents = fsImpl.readFileSync(dotgit, 'utf8').trim() - } catch { - return null - } - - const match = contents.match(/^gitdir:\s*(.+)$/m) - - if (!match) { - return null - } - - const adminDir = path.resolve(host, match[1].trim()) - - // `commondir` resolves to the shared `/.git`; fall back to walking two - // levels up from `/.git/worktrees/` if it's missing. - let commonDir - - try { - const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim() - commonDir = path.resolve(adminDir, rel) - } catch { - commonDir = path.dirname(path.dirname(adminDir)) - } - - return { - repoRoot: path.dirname(commonDir), - worktreeRoot: host, - isMainWorktree: false, - branch: readBranch(adminDir, fsImpl) - } -} - -function resolveWorktree(startPath, fsImpl = fs) { - let resolved - - try { - resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' }) - } catch { - return null - } - - let start = resolved - - try { - const stat = fsImpl.statSync(resolved) - - if (!stat.isDirectory()) { - start = path.dirname(resolved) - } - } catch { - return null - } - - const host = findGitHost(start, fsImpl) - - if (!host) { - return null - } - - return resolveFromHost(host, fsImpl) -} - -// Batch entry point for the renderer: maps each requested cwd to its worktree -// info (or null when it isn't inside a git checkout / can't be read). Dedupes so -// many sessions sharing a cwd cost one lookup. -async function worktreesForIpc(cwds, options = {}) { - const fsImpl = options.fs || fs - const list = Array.isArray(cwds) ? cwds : [] - const out = {} - - for (const cwd of list) { - if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) { - continue - } - - out[cwd] = resolveWorktree(cwd, fsImpl) - } - - return out -} - -module.exports = { - resolveWorktree, - worktreesForIpc -} diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index ce42e3474dc2..5909e1d75c4f 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -55,7 +55,23 @@ const { buildRelaunchScript } = require('./update-relaunch.cjs') const { gitRootForIpc } = require('./git-root.cjs') -const { worktreesForIpc } = require('./git-worktrees.cjs') +const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs') +const { + fileDiffVsHead, + repoStatus, + reviewCommit, + reviewCommitContext, + reviewCreatePr, + reviewDiff, + reviewList, + reviewPush, + reviewRevParse, + reviewRevert, + reviewShipInfo, + reviewStage, + reviewUnstage +} = require('./git-review-ops.cjs') +const { scanGitRepos } = require('./git-repo-scan.cjs') const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') const { runRebuildWithRetry } = require('./update-rebuild.cjs') @@ -1503,6 +1519,30 @@ function resolveGitBinary() { return _gitBinaryCache } +// resolveGhBinary — locate the GitHub CLI. GUI-launched apps get a minimal PATH +// that omits Homebrew (/opt/homebrew/bin, /usr/local/bin) where `gh` usually +// lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's +// terminal. Check the common install locations first, then PATH. Cached. +let _ghBinaryCache = null +function resolveGhBinary() { + if (_ghBinaryCache) return _ghBinaryCache + + const candidates = [] + + if (IS_WINDOWS) { + candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe')) + if (process.env.LOCALAPPDATA) { + candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe')) + } + } else { + const home = app.getPath('home') + candidates.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh', path.join(home, '.local', 'bin', 'gh')) + } + + _ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh' + return _ghBinaryCache +} + function recentHermesLog() { return hermesLog.slice(-20).join('\n') } @@ -2920,7 +2960,6 @@ async function ensureRuntime(backend) { return backend } - function fetchJson(url, token, options = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) @@ -6596,7 +6635,164 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) -ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds)) +// Reveal a path in the OS file manager (Finder / Explorer / Files). +ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + return false + } + + try { + shell.showItemInFolder(target) + + return true + } catch { + return false + } +}) + +// Rename a file/folder in place. The renderer passes the existing path + a new +// base name; the destination is resolved in the SAME parent dir so a rename can +// never move the item elsewhere or traverse out. Rejects on a name collision. +ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { + const src = String(targetPath || '').trim() + const name = String(newName || '').trim() + + if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + throw new Error('Invalid rename') + } + + const dst = path.join(path.dirname(src), name) + + if (dst === src) { + return { path: dst } + } + + if (fs.existsSync(dst)) { + throw new Error(`"${name}" already exists`) + } + + await fs.promises.rename(src, dst) + + return { path: dst } +}) + +// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path +// is hardened (resolveRequestedPathForIpc) and the parent must already exist — +// this never creates directory trees or escapes the allowed roots, and content +// is size-capped so it can't be abused as a bulk-write primitive. +ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { + const raw = String(filePath || '').trim() + + if (!raw) { + throw new Error('Invalid path') + } + + const text = String(content ?? '') + + if (text.length > 1_000_000) { + throw new Error('Content too large') + } + + const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) + + if (!directoryExists(path.dirname(resolved))) { + throw new Error('Parent directory does not exist') + } + + await fs.promises.writeFile(resolved, text, 'utf8') + + return { path: resolved } +}) + +// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" +// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. +ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + throw new Error('Invalid delete') + } + + await shell.trashItem(target) + + return true +}) + +// Git-driven worktree management ("Start work" flow). Errors surface to the +// renderer as rejected promises so it can toast a friendly message. +ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => + listWorktrees(repoPath, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) => + addWorktree(repoPath, options || {}, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) => + removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => + switchBranch(repoPath, branch, resolveGitBinary()) +) + +ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => + listBranches(repoPath, resolveGitBinary()) +) + +// Compact repo status (branch, ahead/behind, change counts + files) for the +// composer coding rail. Returns null on a non-repo / remote backend so the rail +// hides cleanly rather than erroring. +ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary())) + +// Codex-style review pane: list changed files for a scope, fetch one file's +// unified diff, and stage / unstage / revert. Reads return empty on failure; +// mutations reject so the renderer can toast. +ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) => + reviewList(repoPath, scope, baseRef, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) => + reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary()) +) +// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view). +ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) => + fileDiffVsHead(repoPath, filePath, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) => + reviewStage(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) => + reviewUnstage(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) => + reviewRevert(repoPath, filePath ?? null, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) => + reviewRevParse(repoPath, ref, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) => + reviewCommit(repoPath, message, Boolean(push), resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) => + reviewCommitContext(repoPath, resolveGitBinary()) +) +ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary())) +ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary())) +ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) => + reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary()) +) + +// Repo-first project discovery: scan bounded roots for git repos (pure fs walk, +// no native addon). Never throws to the renderer — failures yield an empty list. +ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { + try { + return await scanGitRepos(roots || [], options || {}) + } catch { + return [] + } +}) ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { if (!nodePty) { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 4edba83cf820..aa8bcc161284 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -82,7 +82,35 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'), readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), - worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds), + revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath), + renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName), + writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content), + trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath), + git: { + worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath), + worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options), + worktreeRemove: (repoPath, worktreePath, options) => + ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options), + branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch), + branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath), + repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath), + fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath), + scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options), + review: { + list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef), + diff: (repoPath, filePath, scope, baseRef, staged) => + ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged), + stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath), + unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath), + revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath), + revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref), + commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push), + commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath), + push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath), + shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath), + createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath) + } + }, terminal: { dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id), resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size), diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs new file mode 100644 index 000000000000..bb5b0ad061b9 --- /dev/null +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// bundle-electron-main.mjs — bundles electron/main.cjs into a single +// self-contained file so the nix build doesn't need to ship node_modules/. +// +// `electron` is provided by the runtime; `node-pty` is staged separately +// via stage-native-deps.cjs. `preload.cjs` is NOT require()'d by main — +// Electron loads it via path.join(__dirname, 'preload.cjs') — so it stays +// as a separate file and doesn't need bundling. +import { build } from 'esbuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { renameSync } from 'node:fs' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '..') +const entry = resolve(root, 'electron/main.cjs') +const tmp = resolve(root, 'electron/main.bundled.cjs') + +await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + outfile: tmp, + external: ['electron', 'node-pty'], + logLevel: 'info' +}) + +// Overwrite the original with the bundled version. +renameSync(tmp, entry) + +console.log(`bundled ${entry}`) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 074ba05ef7e6..1ad12008d529 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -93,10 +93,64 @@ declare global { getRecentLogs: () => Promise<{ path: string; lines: string[] }> readDir: (path: string) => Promise gitRoot?: (path: string) => Promise - // Resolve git-worktree identity for a batch of session cwds, reading git's - // on-disk metadata locally. Returns null per cwd that isn't inside a - // checkout (or can't be read — e.g. a remote backend's path). - worktrees?: (cwds: string[]) => Promise> + // Reveal a path in the OS file manager (Finder / Explorer). + revealPath?: (path: string) => Promise + // Rename a file/folder in place (new base name, same parent dir). + renamePath?: (path: string, newName: string) => Promise<{ path: string }> + // Write a small UTF-8 text file (hardened path, parent must exist). + writeTextFile?: (path: string, content: string) => Promise<{ path: string }> + // Move a file/folder to the OS trash (recoverable). + trashPath?: (path: string) => Promise + // Git-driven worktree management for the "Start work" flow. + git?: { + worktreeList: (repoPath: string) => Promise + worktreeAdd: ( + repoPath: string, + options?: { name?: string; branch?: string; base?: string; existingBranch?: string } + ) => Promise<{ path: string; branch: string; repoRoot: string }> + worktreeRemove: ( + repoPath: string, + worktreePath: string, + options?: { force?: boolean } + ) => Promise<{ removed: string }> + branchSwitch: (repoPath: string, branch: string) => Promise<{ branch: string }> + // Local branches for the "convert a branch into a worktree" picker. + branchList: (repoPath: string) => Promise + // Compact working-tree status for the composer coding rail. Null on a + // non-repo / remote backend (where the Electron probe can't run). + repoStatus: (repoPath: string) => Promise + // Working-tree-vs-HEAD unified diff for one file (the preview's diff + // view). Empty string when the file is unchanged or not in a repo. + fileDiff: (repoPath: string, filePath: string) => Promise + // Codex-style review pane: changed files per scope, per-file diff, and + // stage / unstage / revert. + review: { + list: (repoPath: string, scope: HermesReviewScope, baseRef?: null | string) => Promise + diff: ( + repoPath: string, + filePath: string, + scope: HermesReviewScope, + baseRef?: null | string, + staged?: boolean + ) => Promise + stage: (repoPath: string, filePath?: null | string) => Promise<{ ok: boolean }> + unstage: (repoPath: string, filePath?: null | string) => Promise<{ ok: boolean }> + revert: (repoPath: string, filePath?: null | string) => Promise<{ ok: boolean }> + revParse: (repoPath: string, ref?: null | string) => Promise + commit: (repoPath: string, message: string, push: boolean) => Promise<{ ok: boolean }> + // Diff (staged-or-all) + recent commit subjects for drafting a + // commit message. Reads only; empty strings off-repo. + commitContext: (repoPath: string) => Promise<{ diff: string; recent: string }> + push: (repoPath: string) => Promise<{ ok: boolean }> + shipInfo: (repoPath: string) => Promise + createPr: (repoPath: string) => Promise<{ url: string }> + } + // Repo-first discovery: scan bounded roots for git repos (depth-capped). + scanRepos: ( + roots: string[], + options?: { maxDepth?: number } + ) => Promise<{ root: string; label: string }[]> + } terminal: { dispose: (id: string) => Promise onData: (id: string, callback: (payload: string) => void) => () => void @@ -511,16 +565,92 @@ export interface HermesPreviewWatch { path: string } -export interface HermesWorktreeInfo { - // Main repo root — the shared grouping key for a checkout and all its linked - // worktrees. - repoRoot: string - // This cwd's own worktree root. - worktreeRoot: string - // True when this is the repo's primary checkout (.git is a directory). - isMainWorktree: boolean - // Current branch (or short detached-HEAD sha), null when unreadable. +// A real git worktree as reported by `git worktree list` (source of truth for +// the "Start work" flow), as opposed to the session-cwd-derived grouping above. +export interface HermesGitWorktree { + path: string branch: null | string + isMain: boolean + detached: boolean + locked: boolean +} + +// A local branch as offered by the "convert a branch into a worktree" picker. +// `checkedOut` marks branches git won't let a second worktree claim. +export interface HermesGitBranch { + name: string + checkedOut: boolean + worktreePath: null | string +} + +// A single changed path from `git status --porcelain=v2`, classified by state +// so the coding rail / switcher can group + open the right diff. +export interface HermesRepoStatusFile { + path: string + staged: boolean + unstaged: boolean + untracked: boolean + conflicted: boolean +} + +// Compact working-tree status for the composer coding rail (parsed from +// `git status --porcelain=v2 --branch`). +export interface HermesRepoStatus { + branch: null | string + // The repo's trunk ("main" / "master" / …), so the UI can offer "branch off + // the default" from anywhere. Null when no trunk is detected. + defaultBranch: null | string + detached: boolean + ahead: number + behind: number + staged: number + unstaged: number + untracked: number + conflicted: number + // Total distinct changed paths (tracked modified + conflicts + untracked). + changed: number + // +/- line counts of tracked changes vs HEAD (staged + unstaged). Untracked + // files aren't in the diff, so they don't contribute lines. + added: number + removed: number + // Capped changed-file list (REPO_STATUS_FILE_CAP) for the diff/open actions. + files: HermesRepoStatusFile[] +} + +// Diff scope for the review pane, mirroring Codex: uncommitted working-tree +// changes, all changes vs the branch base, or everything since the current +// turn began. +export type HermesReviewScope = 'branch' | 'lastTurn' | 'uncommitted' + +// One changed file in the review pane (status letter, +/- lines, staged flag). +export interface HermesReviewFile { + path: string + added: number + removed: number + // M(odified) A(dded) D(eleted) R(enamed) C(opied) U(nmerged) ?(untracked) + status: string + staged: boolean +} + +export interface HermesReviewList { + files: HermesReviewFile[] + // The resolved base ref the scope diffed against (branch merge-base / turn + // baseline), or null for the uncommitted scope. + base: null | string +} + +// The branch's PR (if any) as reported by `gh pr view`. +export interface HermesReviewPr { + url: string + state: string + number: number +} + +// gh availability/auth + the current branch's PR — drives the review pane's PR +// button (disabled when gh isn't ready, "Open PR" vs "Create PR" otherwise). +export interface HermesReviewShipInfo { + ghReady: boolean + pr: HermesReviewPr | null } export interface HermesReadDirEntry {