mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
Open checked-out branches, switch the primary checkout for the default branch, and create linked worktrees only for non-trunk free branches.
339 lines
9 KiB
JavaScript
339 lines
9 KiB
JavaScript
'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'
|
|
}
|
|
|
|
const TRUNK_BRANCHES = ['main', 'master']
|
|
|
|
async function gitLine(gitBin, args, cwd) {
|
|
try {
|
|
return (await runGit(gitBin, args, cwd)).trim()
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
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 branch = sanitizeBranch(name)
|
|
|
|
if (!branch) {
|
|
throw new Error('Branch name is required.')
|
|
}
|
|
|
|
if (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)))
|
|
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) {
|
|
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]))
|
|
const trunk = await defaultBranch(gitBin, resolved)
|
|
|
|
return out
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(Boolean)
|
|
.map(name => ({
|
|
name,
|
|
checkedOut: pathByBranch.has(name),
|
|
isDefault: Boolean(trunk && name === trunk),
|
|
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
|
|
}
|