// 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. import fs from 'node:fs' import path from 'node:path' import { resolveRequestedPathForIpc } from './hardening' import { execGit } from './no-console-git' function runGit(gitBin, args, cwd): Promise { return execGit(gitBin, args, { cwd, timeoutMs: 30_000 }).then(result => { if (result.code !== 0) { const error = new Error(result.stderr || `git exited ${result.code}`) as Error & { stderr?: string } error.stderr = result.stderr throw error } return result.stdout }) } // Fetch `/` by explicit refspec; true when the remote has the // branch. A tag-pinned narrow clone maps only the tag in remote..fetch, // so a by-name fetch writes FETCH_HEAD without creating the tracking ref // (#125686). Same refspec as `hermes update`: the `+` matters on a depth-1 // clone, where the new tip need not descend from the old one. async function fetchTrackingRef(gitBin, root, remote, branch): Promise { return gitOk(gitBin, ['fetch', remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`], root) } // 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' } const TRUNK_BRANCHES = ['main', 'master'] async function gitLine(gitBin, args, cwd) { try { return (await runGit(gitBin, args, cwd)).trim() } catch { return '' } } // True when the command exits 0. Use this function and not `gitLine` for a // `--quiet` probe. A `--quiet` probe prints nothing when it finds the ref, and // that output is the same as the output of a failure. async function gitOk(gitBin, args, cwd) { try { await runGit(gitBin, args, cwd) return true } catch { return false } } // The remote that a ref belongs to ("origin" for "origin/main"), or "" when the // name is not a remote-tracking ref in this repo. This function asks git. It // does not assume that the remote has the name "origin", because a repo can // give its remotes any name. async function remoteOfRef(gitBin, cwd, name) { if (!name.includes('/')) { return '' } if (!(await gitOk(gitBin, ['show-ref', '--verify', '--quiet', `refs/remotes/${name}`], cwd))) { return '' } return name.slice(0, name.indexOf('/')) } async function defaultBranch(gitBin, cwd) { const remote = ( await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd) ).replace(/^origin\//, '') if (remote) { return remote } const configured = await gitLine(gitBin, ['config', '--get', 'init.defaultBranch'], cwd) if (configured) { return configured } for (const branch of TRUNK_BRANCHES) { if (await gitLine(gitBin, ['show-ref', '--verify', `refs/heads/${branch}`], cwd)) { return branch } } return '' } // 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 addExistingBranchWorktree(gitBin, root, name) { const requested = sanitizeBranch(name) if (!requested) { throw new Error('Branch name is required.') } // "origin/feature" is a remote-tracking ref and not a branch that git can // check out. `git worktree add origin/feature` detaches HEAD. Make a // local branch with the same short name that tracks the remote ref. This is // what `git switch feature` does for a branch on exactly one remote. let remote = await remoteOfRef(gitBin, root, requested) let fetched = false if ( !remote && requested.includes('/') && !(await gitOk(gitBin, ['show-ref', '--verify', '--quiet', `refs/heads/${requested}`], root)) ) { // A tag-pinned narrow clone has no tracking ref for any branch, so the // ref-based reading above misreads "origin/feature" as a local branch. When // no such local branch exists and the remote carries the branch, fetching // it creates the ref; otherwise keep the local-branch reading and its error. const maybeRemote = requested.slice(0, requested.indexOf('/')) if ( (await gitLine(gitBin, ['remote', 'get-url', maybeRemote], root)) && (await fetchTrackingRef(gitBin, root, maybeRemote, requested.slice(maybeRemote.length + 1))) ) { remote = maybeRemote fetched = true } } const branch = remote ? requested.slice(remote.length + 1) : requested if (!remote && branch === (await defaultBranch(gitBin, root))) { await runGit(gitBin, ['switch', branch], root) return { path: root, branch, repoRoot: root } } const dir = uniqueDir(path.join(root, '.worktrees', slugify(branch))) if (remote) { // Best effort freshness: after a failure (offline, branch gone from the // remote) the last known ref is still there to branch from. fetched = fetched || (await fetchTrackingRef(gitBin, root, remote, branch)) if (!(await gitOk(gitBin, ['worktree', 'add', '--track', '-b', branch, dir, requested], root))) { // `--track` needs remote..fetch to map the ref back to a remote // branch; a narrow clone maps only its tag. Branch untracked, then // register the branch and wire upstream, but only for a branch the fetch // just proved exists: a configured refspec whose source is gone makes // every later plain `git fetch` fail. await runGit(gitBin, ['worktree', 'add', '-b', branch, dir, requested], root) if (fetched) { await gitOk(gitBin, ['remote', 'set-branches', '--add', remote, branch], root) await gitOk(gitBin, ['branch', `--set-upstream-to=${requested}`, branch], root) } } return { path: dir, branch, repoRoot: root } } await runGit(gitBin, ['worktree', 'add', dir, branch], root) return { path: dir, branch, repoRoot: root } } 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 || {} if (opts.existingBranch) { return addExistingBranchWorktree(gitBin, root, opts.existingBranch) } 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) { // Remote-tracking branches may be stale or missing if the user hasn't // fetched recently. When the base is an `origin/…` ref, fetch just that // branch so `git worktree add -b new origin/main` works against the // latest remote commit. Local branches are used as-is. const base = String(opts.base) if (base.startsWith('origin/')) { const remoteBranch = base.slice('origin/'.length) // `base` comes straight from IPC, and inside a refspec a glob such as // "origin/*" would fetch every branch: only fetch valid branch names. // The fetch is best effort; git uses the local ref or raises a clear // error below if it is entirely missing. if (await gitOk(gitBin, ['check-ref-format', '--branch', remoteBranch], root)) { await fetchTrackingRef(gitBin, root, 'origin', remoteBranch) } // When branching off a remote-tracking ref, git auto-sets up tracking // (e.g. `new-branch` → tracks `origin/main`). The user almost certainly // wants a standalone local branch — like `git checkout origin/main && // git checkout -b new-branch` — not a branch silently wired to the // remote's upstream. `--no-track` prevents that. args.push('--no-track') } args.push(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 the branches for the "convert a branch into a worktree" picker, most // recently committed first. The local heads come first. Then come the // remote-tracking refs that have no local branch yet. This is the same set that // the base-branch picker offers, so "convert" can reach a teammate's branch // that the user did not check out. // Each branch carries a flag for a checkout in a worktree, and the path of that // worktree. Empty on a non-repo or a remote backend, where the probe cannot // run. async function listBranches(repoPath, gitBin) { let resolved try { resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch list' }) } catch { return [] } try { // Both children own cwd handles: a failed probe must still wait for its // sibling before the caller may remove or switch the repository directory. const probes = await Promise.allSettled([ runGit(gitBin, ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], resolved), runGit(gitBin, ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/remotes'], resolved) ]) const [local, remote] = probes if (local.status === 'rejected' || remote.status === 'rejected') { return [] } const [localOut, remoteOut] = [local.value, remote.value] const trees = await listWorktrees(resolved, gitBin) const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path])) const trunk = await defaultBranch(gitBin, resolved) const names = (out: string) => out .split('\n') .map(line => line.trim()) .filter(Boolean) const locals = names(localOut) const localSet = new Set(locals) const remotes = names(remoteOut).filter(name => { // "origin/HEAD" is a symbolic alias for the default branch of the remote. // It is not a branch, and it shows in the list as a duplicate. if (name.endsWith('/HEAD')) { return false } // The user reaches a remote branch that they track locally through its // local head. To list both is noise, and a checkout of the // remote-tracking ref detaches HEAD. return !localSet.has(name.slice(name.indexOf('/') + 1)) }) return [ ...locals.map(name => ({ name, checkedOut: pathByBranch.has(name), isDefault: Boolean(trunk && name === trunk), isRemote: false, worktreePath: pathByBranch.get(name) || null })), ...remotes.map(name => ({ // A remote branch has no local checkout, and it cannot be the local // trunk. It is therefore never checked out and never the default. name, checkedOut: false, isDefault: false, isRemote: true, worktreePath: null })) ] } catch { return [] } } async function switchBranch(repoPath, branch, gitBin) { const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch switch' }) // Sidebar lanes exist for plain folders too (non-repo explicit projects), // and their lane label is the folder basename — not a branch. `git switch` // there is meaningless, and sanitizing that label would throw a misleading // "Branch name is required." — so short-circuit for non-repo roots and let // callers (e.g. "+" new session on the project lane) proceed with a plain // session instead of aborting. let inside = 'false' try { inside = (await runGit(gitBin, ['rev-parse', '--is-inside-work-tree'], resolved)).trim() } catch { // Not a git repo (or git unavailable): fall through to the short-circuit. } if (inside !== 'true') { return { branch: null } } const target = sanitizeBranch(branch) if (!target) { throw new Error('Branch name is required.') } await runGit(gitBin, ['switch', target], resolved) return { branch: target } } // Branches the new worktree can be based on: local heads + remote-tracking // refs. Listed most-recently-committed first; the remote's default branch // (origin/HEAD) is flagged so the UI can preselect it. Empty on a non-repo / // remote backend where the probe can't run. async function listBaseBranches(repoPath, gitBin) { let resolved try { resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Base branch list' }) } catch { return [] } try { const out = await runGit( gitBin, [ 'for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes' ], resolved ) const remoteDefault = await gitLine( gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved ) const localDefault = await defaultBranch(gitBin, resolved) return out .split('\n') .map(line => line.trim()) .filter(Boolean) .map(line => { const [name] = line.split('\t') return { name, isRemote: name.startsWith('origin/'), // origin/HEAD when a remote exists; otherwise the local default // (main/master/init.defaultBranch) so a no-remote repo still flags // its trunk. isDefault: Boolean( (remoteDefault && name === remoteDefault) || (!remoteDefault && localDefault && name === localDefault) ) } }) } catch { return [] } } export { addWorktree, ensureGitRepo, listBaseBranches, listBranches, listWorktrees, parseWorktrees, removeWorktree, sanitizeBranch, switchBranch }