Files
hermes-agent/apps/desktop/electron/git-worktree-ops.ts
kshitijk4poor 77019b3b7d fix(worktree): refresh any valid branch name used as a base
The glob guard compared the name with `_sanitize_branch`, which also
rewrites valid names such as "fix+1" or "feat/foo@bar". Their base
fetch was skipped, so the new worktree started from a stale tracking
ref. The Python and Electron guards also disagreed on non-ASCII names,
because Python's `\w` is Unicode and JavaScript's is ASCII.

Ask git instead: `git check-ref-format --branch` accepts every valid
branch name and rejects the refspec metacharacters (`* ? [ : ^ \`),
and both twins now make the same call.
2026-09-28 16:23:22 +05:30

578 lines
18 KiB
TypeScript

// 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<string> {
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 `<remote>/<branch>` by explicit refspec; true when the remote has the
// branch. A tag-pinned narrow clone maps only the tag in remote.<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<boolean> {
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 <dir> 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.<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
}