mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-17 14:42:06 +00:00
feat(desktop): ts-ify everything
This commit is contained in:
parent
fac85518fc
commit
39d09453f9
116 changed files with 2500 additions and 1756 deletions
|
|
@ -1,350 +0,0 @@
|
|||
'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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue