LLeMbas CLI 1.0.0
ci / check (push) Waiting to run

The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM
API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a
link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64
and arm64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-09 21:59:03 +00:00
commit f9bad01ed7
355 files changed
+47028

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
// /branch: list, switch to, make and delete local branches. Plain git, and git's own refusals
// stand: a switch that would overwrite uncommitted work, or deleting a branch that is not merged,
// is refused by git and the reason is shown — nothing here forces either.
import { git } from "./run.ts"
export interface Branch {
name: string
current: boolean
/** Last commit: short hash and subject. */
last: string
/** Unix ms of the last commit. */
at: number
upstream?: string
}
export function branches(root: string): Branch[] {
const r = git(root, ["for-each-ref", "--sort=-committerdate", "--format=%(HEAD)%00%(refname:short)%00%(objectname:short) %(contents:subject)%00%(committerdate:unix)%00%(upstream:short)", "refs/heads/"])
if (r.code !== 0 || !r.out) return []
return r.out.split("\n").map((l) => {
const [head = "", name = "", last = "", at = "0", upstream = ""] = l.split("\0")
return { name, current: head === "*", last, at: Number(at) * 1000, upstream: upstream || undefined }
})
}
export function currentBranch(root: string): string | undefined {
const r = git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])
return r.code === 0 ? r.out : undefined
}
/** Why a name cannot be a branch, or undefined when it can. */
export function badBranchName(root: string, name: string): string | undefined {
if (!name) return "no name given"
if (name.startsWith("-")) return "a branch name cannot start with -"
return git(root, ["check-ref-format", "--branch", name]).code === 0 ? undefined : `"${name}" is not a valid branch name`
}
export interface BranchResult {
ok: boolean
text: string
}
/** Switch to a branch, making it from HEAD when it does not exist yet. Uncommitted changes go
* along when git can carry them; when it cannot, git refuses and nothing changes. */
export function switchBranch(root: string, name: string): BranchResult {
const bad = badBranchName(root, name)
if (bad) return { ok: false, text: bad }
if (currentBranch(root) === name) return { ok: true, text: `already on ${name}` }
const exists = git(root, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]).code === 0
const r = git(root, exists ? ["switch", "--quiet", name] : ["switch", "--quiet", "-c", name])
if (r.code !== 0) return { ok: false, text: r.err || r.out }
return { ok: true, text: exists ? `switched to ${name}` : `made ${name} from ${git(root, ["log", "-1", "--format=%h %s"]).out || "an empty history"} and switched to it` }
}
/** `git branch -d`: only a branch whose work is merged. The current branch cannot be deleted. */
export function deleteBranch(root: string, name: string): BranchResult {
if (currentBranch(root) === name) return { ok: false, text: `${name} is the current branch — switch to another first` }
const r = git(root, ["branch", "-d", "--", name])
if (r.code === 0) return { ok: true, text: `deleted ${name}` }
if (/not fully merged/.test(r.err)) return { ok: false, text: `${name} has work that is not merged anywhere, so it was kept. If it really should go: git branch -D ${name}` }
return { ok: false, text: r.err || r.out }
}
+79
View File
@@ -0,0 +1,79 @@
// /commit: what to commit, a drafted message, and the commit itself. Files are always staged by
// name — never `git add -A` — so nothing the session did not touch rides along.
import { readFileSync } from "node:fs"
import { join } from "node:path"
import type { Client } from "../provider/types.ts"
import { git } from "./run.ts"
export interface CommitPlan {
files: string[]
/** true: the user had already staged these; commit exactly the index. */
staged: boolean
}
/** The index if anything is staged; otherwise the session's changed files that git sees as changed. */
export function commitPlan(root: string, touched: string[], all = false): CommitPlan {
const staged = git(root, ["diff", "--cached", "--name-only", "-z"]).out.split("\0").filter(Boolean)
if (staged.length && !all) return { files: staged, staged: true }
const status = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]).out.split("\0").filter(Boolean)
const changed = status.map((l) => l.slice(3))
const files = all ? changed : changed.filter((f) => touched.includes(f))
return { files, staged: false }
}
const PROMPT = `Write a git commit message for the change below.
- First line: a summary under 72 characters, imperative mood, in the style of the recent commits shown.
- Then, only if the change needs explaining, a blank line and a short body saying what changed and why — not a list of files.
- Reply with the message only: no quotes, no code fences, no preamble.
Recent commits, for style:
{{recent}}
The change:
{{diff}}`
export async function draftMessage(client: Client, root: string, plan: CommitPlan, signal?: AbortSignal): Promise<string> {
const recent = git(root, ["log", "-10", "--format=%s"]).out || "(none yet)"
const diff = plan.staged
? git(root, ["diff", "--cached", "--no-color", "--stat", "-p"]).out
: [
git(root, ["diff", "--no-color", "--stat", "-p", "HEAD", "--", ...plan.files]).out ||
git(root, ["diff", "--no-color", "-p", "--", ...plan.files]).out,
...untrackedPreview(root, plan.files),
].join("\n")
const prompt = PROMPT.replace("{{recent}}", () => recent).replace("{{diff}}", () => diff.slice(0, 24_000))
let text = ""
for await (const ev of client.stream({ system: "", messages: [{ role: "user", parts: [{ type: "text", text: prompt }] }], tools: [], effort: null, signal }))
if (ev.type === "finish") text = ev.message.parts.map((p) => (p.type === "text" ? p.text : "")).join("")
return cleanMessage(text)
}
/** New files have no diff against HEAD; show the model their first lines instead. */
function untrackedPreview(root: string, files: string[]): string[] {
const untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...files]).out.split("\0").filter(Boolean)
return untracked.map((f) => {
let head = ""
try {
head = readFileSync(join(root, f), "utf8").split("\n").slice(0, 40).join("\n")
} catch {}
return `new file ${f}:\n${head}`
})
}
export function cleanMessage(text: string): string {
let t = text.trim().replace(/^```\w*\n?/, "").replace(/\n?```$/, "").trim()
t = t.replace(/^(commit message|message):\s*/i, "").replace(/^["'](.*)["']$/s, "$1")
return t
}
export function commit(root: string, plan: CommitPlan, message: string): { ok: boolean; text: string } {
if (!plan.staged && plan.files.length) {
const add = git(root, ["add", "--", ...plan.files])
if (add.code !== 0) return { ok: false, text: add.err }
}
const r = git(root, ["commit", "-q", "-F", "-"], { input: message })
if (r.code !== 0) return { ok: false, text: r.err || r.out }
const head = git(root, ["log", "-1", "--format=%h %s"]).out
return { ok: true, text: head }
}
+228
View File
@@ -0,0 +1,228 @@
// /release: bump the version where the project keeps it, move CHANGELOG's Unreleased section
// under the new version, commit both files by name, and make an annotated tag — signed when git
// is set up to sign — whose message is that section. Nothing is pushed unless asked.
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { basename, join } from "node:path"
import { git } from "./run.ts"
export interface VersionFile {
file: string
version: string
/** The file's text with the version replaced. */
with(next: string): string
/** Other files that repeat the version and must move with it (package-lock.json). */
also?: { file: string; with(next: string): string }[]
}
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
const VERSIONISH = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?`
/** Replace the first match of `re` (whose group 2 is the version) in `text`. */
const swap = (text: string, re: RegExp, next: string) => text.replace(re, (_m, a: string, _v: string, b: string) => `${a}${next}${b}`)
function tomlVersion(text: string, table: string): string | undefined {
const sec = new RegExp(`^\\[${table.replace(".", "\\.")}\\]\\s*$([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, "m").exec(text)
return sec ? /^version\s*=\s*["']([^"']+)["']/m.exec(sec[1]!)?.[1] : undefined
}
function tomlWith(text: string, table: string, next: string): string {
const re = new RegExp(`(^\\[${table.replace(".", "\\.")}\\]\\s*$[\\s\\S]*?^version\\s*=\\s*["'])([^"']+)(["'])`, "m")
return swap(text, re, next)
}
/** package.json-style: the top-level "version" only (a nested one is someone else's), rewritten
* with the file's own indentation. */
function jsonVersion(text: string): { version: string; with(next: string): string } | undefined {
let j: any
try {
j = JSON.parse(text)
} catch {
return undefined
}
if (typeof j?.version !== "string") return undefined
const indent = /^\{\s*\n([ \t]+)"/.exec(text)?.[1] ?? " "
return {
version: j.version,
with: (next) => {
const o = JSON.parse(text)
o.version = next
// package-lock.json repeats it for the root package
if (o.packages?.[""] && typeof o.packages[""].version === "string") o.packages[""].version = next
return JSON.stringify(o, null, indent) + (text.endsWith("\n") ? "\n" : "")
},
}
}
/** Where the project's version lives: the first of package.json, pyproject.toml, Cargo.toml, a
* package's __init__.py, VERSION. The tree rule: one source of the version. */
export function findVersion(root: string): VersionFile | undefined {
const read = (f: string) => (existsSync(join(root, f)) ? readFileSync(join(root, f), "utf8") : undefined)
const pkg = read("package.json")
const pv = pkg ? jsonVersion(pkg) : undefined
if (pv) {
const lock = read("package-lock.json")
const lv = lock ? jsonVersion(lock) : undefined
return { file: "package.json", version: pv.version, with: pv.with, also: lv && lv.version === pv.version ? [{ file: "package-lock.json", with: lv.with }] : [] }
}
const py = read("pyproject.toml")
if (py) {
for (const t of ["project", "tool.poetry"]) {
const v = tomlVersion(py, t)
if (v) return { file: "pyproject.toml", version: v, with: (n) => tomlWith(py, t, n) }
}
}
const cargo = read("Cargo.toml")
if (cargo) {
for (const t of ["package", "workspace.package"]) {
const v = tomlVersion(cargo, t)
if (v) return { file: "Cargo.toml", version: v, with: (n) => tomlWith(cargo, t, n) }
}
}
for (const base of [".", "src"]) {
const dir = join(root, base)
if (!existsSync(dir)) continue
for (const d of readdirSync(dir, { withFileTypes: true })) {
if (!d.isDirectory() || d.name.startsWith(".")) continue
const rel = join(base === "." ? "" : base, d.name, "__init__.py")
const text = read(rel)
const v = text && /^__version__\s*=\s*["']([^"']+)["']/m.exec(text)?.[1]
if (text && v) return { file: rel, version: v, with: (n) => swap(text, /^(__version__\s*=\s*["'])([^"']+)(["'])/m, n) }
}
}
const plain = read("VERSION")?.trim()
if (plain) return { file: "VERSION", version: plain, with: (n) => `${n}\n` }
return undefined
}
/** The next version: patch, minor, major, or one given outright. */
export function nextVersion(current: string, how: string): string {
if (/^v\d/.test(how)) how = how.slice(1)
if (SEMVER.test(how)) return how
const m = SEMVER.exec(current)
if (!m) throw new Error(`the current version "${current}" is not x.y.z; give the new one outright`)
const [maj, min, pat] = [Number(m[1]), Number(m[2]), Number(m[3])]
// A pre-release goes to its own release: 1.2.0-beta.3 → 1.2.0.
if (m[4] && (how === "" || how === "patch")) return `${maj}.${min}.${pat}`
if (how === "major") return `${maj + 1}.0.0`
if (how === "minor") return `${maj}.${min + 1}.0`
if (how === "patch" || how === "") return `${maj}.${min}.${pat + 1}`
throw new Error(`"${how}" is not patch, minor, major or a version`)
}
export interface ChangelogRelease {
/** CHANGELOG.md with the Unreleased entries under the new version. */
text: string
/** The entries: the tag message's body. */
notes: string
}
/** Keep a Changelog: `## [Unreleased]` keeps its place, empty, and its entries move under
* `## [X.Y.Z] — date`. undefined when there is nothing under Unreleased. */
export function releaseChangelog(text: string, version: string, date: string): ChangelogRelease | undefined {
const head = /^## \[?Unreleased\]?[^\n]*\n/im.exec(text)
if (!head) return undefined
const start = head.index + head[0].length
const next = /^## /m.exec(text.slice(start))
const end = next ? start + next.index : text.length
const notes = text.slice(start, end).trim()
if (!notes) return undefined
const out = `${text.slice(0, start)}\n## [${version}] — ${date}\n\n${notes}\n\n${text.slice(end)}`
// Link references at the bottom, if the file keeps them: Unreleased compares from the new tag.
const withLinks = out.replace(new RegExp(`^\\[Unreleased\\]:\\s*(\\S+?)\\/compare\\/(v?)(${VERSIONISH})\\.\\.\\.HEAD\\s*$`, "im"), (_m, base: string, v: string, prev: string) => `[Unreleased]: ${base}/compare/${v}${version}...HEAD\n[${version}]: ${base}/compare/${v}${prev}...${v}${version}`)
return { text: withLinks.replace(/\n{3,}/g, "\n\n"), notes }
}
export interface ReleasePlan {
/** Only the tag is missing: HEAD is already "Release <version>" (a tag that failed last time). */
tagOnly?: boolean
/** The tag message's body when there is no changelog move to take it from. */
notes?: string
root: string
name: string
from: string
to: string
tag: string
versionFile: VersionFile
changelog?: ChangelogRelease
sign: boolean
/** Why it cannot go ahead, if it cannot. */
blocked?: string
}
const today = () => new Date().toISOString().slice(0, 10)
/** v-prefixed tags unless the repository's tags are not. */
const tagPrefix = (root: string) => (git(root, ["tag", "--list", "v[0-9]*"]).out || !git(root, ["tag", "--list"]).out ? "v" : "")
const signs = (root: string) => git(root, ["config", "--bool", "tag.gpgsign"]).out === "true" || git(root, ["config", "user.signingkey"]).out !== ""
function projectName(root: string): string {
try {
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name?.replace(/^@[^/]+\//, "") || basename(root)
} catch {
return basename(root)
}
}
export function planRelease(root: string, how: string): ReleasePlan | { blocked: string } {
const vf = findVersion(root)
if (!vf) return { blocked: "no version found (package.json, pyproject.toml, Cargo.toml, __init__.py __version__ or VERSION)" }
const prefix = tagPrefix(root)
// The last /release committed but could not tag (a locked key): finish that one, do not skip it.
if (!how.trim() && git(root, ["log", "-1", "--format=%s"]).out === `Release ${vf.version}` && git(root, ["rev-parse", "-q", "--verify", `refs/tags/${prefix}${vf.version}`]).code !== 0) {
const cl = existsSync(join(root, "CHANGELOG.md")) ? readFileSync(join(root, "CHANGELOG.md"), "utf8") : ""
const sec = new RegExp(`^## \\[?${vf.version.replace(/\./g, "\\.")}\\]?[^\\n]*\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, "m").exec(cl)?.[1]?.trim()
return { root, name: projectName(root), from: vf.version, to: vf.version, tag: `${prefix}${vf.version}`, versionFile: vf, sign: signs(root), tagOnly: true, notes: sec }
}
let to: string
try {
to = nextVersion(vf.version, how.trim())
} catch (e) {
return { blocked: (e as Error).message }
}
const tag = `${prefix}${to}`
if (git(root, ["rev-parse", "-q", "--verify", `refs/tags/${tag}`]).code === 0) return { blocked: `the tag ${tag} already exists` }
const dirty = git(root, ["status", "--porcelain", "--untracked-files=no"]).out
if (dirty) return { blocked: `the working tree has uncommitted changes — commit them first:\n${dirty}` }
const cl = existsSync(join(root, "CHANGELOG.md")) ? releaseChangelog(readFileSync(join(root, "CHANGELOG.md"), "utf8"), to, today()) : undefined
return { root, name: projectName(root), from: vf.version, to, tag, versionFile: vf, changelog: cl, sign: signs(root) }
}
/** Do it: write, commit by name, tag. Returns what happened, line by line. */
export function doRelease(p: ReleasePlan, push = false): { ok: boolean; lines: string[] } {
const lines: string[] = []
// The plan may be minutes old: the tree and the version must still be what it saw.
const dirty = git(p.root, ["status", "--porcelain", "--untracked-files=no"]).out
if (dirty) return { ok: false, lines: [`the working tree changed since the plan — nothing done:\n${dirty}`] }
if (findVersion(p.root)?.version !== p.from) return { ok: false, lines: ["the version changed since the plan — nothing done; run /release again"] }
const files = [p.versionFile.file]
if (p.from !== p.to) {
writeFileSync(join(p.root, p.versionFile.file), p.versionFile.with(p.to))
for (const a of p.versionFile.also ?? []) {
writeFileSync(join(p.root, a.file), a.with(p.to))
files.push(a.file)
}
}
if (p.changelog) {
writeFileSync(join(p.root, "CHANGELOG.md"), p.changelog.text)
files.push("CHANGELOG.md")
}
const add = git(p.root, ["add", "--", ...files])
if (add.code) return { ok: false, lines: [`git add: ${add.err}`] }
if (git(p.root, ["diff", "--cached", "--quiet"]).code !== 0) {
const c = git(p.root, ["commit", "-q", "-m", `Release ${p.to}`])
if (c.code) return { ok: false, lines: [`commit failed: ${c.err || c.out}`] }
lines.push(`committed ${git(p.root, ["log", "-1", "--format=%h %s"]).out}`)
}
const message = `${p.name} ${p.to}\n\n${p.changelog?.notes ?? p.notes ?? ""}`.trim() + "\n"
// verbatim: by default git strips every line starting with "#" — the "### Added" headings.
const t = git(p.root, ["tag", p.sign ? "-s" : "-a", "--cleanup=verbatim", p.tag, "-F", "-"], { input: message })
if (t.code) return { ok: false, lines: [...lines, `tag failed: ${t.err || t.out}`] }
lines.push(`tagged ${p.tag}${p.sign ? " (signed)" : ""}`)
if (push) {
// The tag goes where the branch goes.
const remote = git(p.root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).out.split("/")[0] || "origin"
const branch = git(p.root, ["push", "-q"])
const tag = branch.code ? branch : git(p.root, ["push", "-q", remote, p.tag])
if (tag.code) return { ok: false, lines: [...lines, `push failed: ${tag.err || tag.out}`] }
lines.push(`pushed the branch and ${p.tag}`)
} else lines.push(`nothing pushed — when ready: git push && git push ${git(p.root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).out.split("/")[0] || "origin"} ${p.tag}`)
return { ok: true, lines }
}
+50
View File
@@ -0,0 +1,50 @@
import { git } from "./run.ts"
export interface RepoState {
branch: string
upstream?: string
ahead: number
behind: number
/** Changed paths, as `git status --porcelain` codes: " M src/a.ts", "?? new.txt". */
changes: string[]
recent: string[]
detached: boolean
}
/** Branch, divergence, working-tree changes and the last commits — or undefined outside a repo. */
export function repoState(root: string, commits = 5): RepoState | undefined {
const st = git(root, ["status", "--porcelain=v1", "--branch", "--untracked-files=normal"])
if (st.code !== 0) return undefined
const [head = "", ...changes] = st.out.split("\n")
// "## main...origin/main [ahead 2, behind 1]" | "## HEAD (no branch)" | "## No commits yet on main"
const m = /^## (?:No commits yet on )?(.+?)(?:\.\.\.(\S+))?(?: \[(.+)\])?$/.exec(head)
const detached = head.includes("(no branch)")
const info = m?.[3] ?? ""
const log = git(root, ["log", `-${commits}`, "--format=%h %s"])
return {
branch: detached ? "HEAD (detached)" : (m?.[1] ?? "?"),
upstream: m?.[2],
ahead: Number(/ahead (\d+)/.exec(info)?.[1] ?? 0),
behind: Number(/behind (\d+)/.exec(info)?.[1] ?? 0),
changes: changes.filter(Boolean),
recent: log.code === 0 && log.out ? log.out.split("\n") : [],
detached,
}
}
/** The git block of the system prompt. */
export function describeRepo(s: RepoState): string {
const lines = [`Branch: ${s.branch}${s.upstream ? ` (tracking ${s.upstream}${s.ahead || s.behind ? `, ahead ${s.ahead}, behind ${s.behind}` : ", up to date"})` : " (no upstream)"}`]
if (s.changes.length) {
const shown = s.changes.slice(0, 20).map((c) => ` ${c}`)
lines.push(`Uncommitted changes (${s.changes.length}):`, ...shown, ...(s.changes.length > 20 ? [` … ${s.changes.length - 20} more`] : []))
} else lines.push("Working tree clean.")
if (s.recent.length) lines.push("Recent commits:", ...s.recent.map((c) => ` ${c}`))
else lines.push("No commits yet.")
return lines.join("\n")
}
export function gitInit(root: string): { ok: boolean; message: string } {
const r = git(root, ["init", "-q", "-b", "main"])
return r.code === 0 ? { ok: true, message: `initialised an empty git repository in ${root} (branch main, no remote)` } : { ok: false, message: r.err }
}
+23
View File
@@ -0,0 +1,23 @@
// Plain git, synchronously. Every call is scoped with -C; output is trimmed text.
export interface GitResult {
code: number
out: string
err: string
}
export function git(cwd: string, args: string[], opts: { env?: Record<string, string>; input?: string; timeoutMs?: number } = {}): GitResult {
// core.fsmonitor names a program git runs on status — off for every call made here, before and
// after a project is trusted.
const r = Bun.spawnSync(["git", "-c", "core.quotepath=false", "-c", "core.fsmonitor=false", "-C", cwd, ...args], {
// Nothing may hang the app: a signing key that wants a passphrase, a slow hook.
timeout: opts.timeoutMs ?? 120_000,
stdout: "pipe",
stderr: "pipe",
stdin: opts.input !== undefined ? new TextEncoder().encode(opts.input) : "ignore",
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", LC_ALL: "C", ...opts.env },
})
const err = r.stderr.toString().trim()
return { code: r.exitCode ?? 1, out: r.stdout.toString().replace(/\s+$/, ""), err: r.exitCode === null && !err ? `git ${args[0]} did not finish in ${Math.round((opts.timeoutMs ?? 120_000) / 1000)} s (a key waiting for a passphrase? a slow hook?)` : err }
}
export const hasGit = Bun.which("git") !== null
+193
View File
@@ -0,0 +1,193 @@
// Snapshots of the working tree in a shadow git repository — the idea of OpenCode's
// snapshot/index.ts, written small. The shadow repo lives in ~/.local/share/lembas/snapshot/,
// never inside the project, and works whether or not the project itself uses git.
//
// track() → a tree hash for the working tree as it is now
// changed(a, b) → paths that differ between two trees
// restore(tree, ps) → put those paths back as they were in `tree` (deleting ones it lacks)
// checkpoint(label) → a named snapshot that outlives the session (/checkpoint)
//
// Restoring by path, never the whole tree, is what makes /undo safe: files the turn did not
// touch are left alone, whatever happened to them since.
import { createHash } from "node:crypto"
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { paths } from "../config/paths.ts"
import { LOCAL_DIRS } from "../project/root.ts"
import { git } from "./run.ts"
const MAX_FILES = 20_000
const MAX_FILE_BYTES = 2 * 1024 * 1024
/** A named snapshot: a commit in the shadow store under refs/checkpoints/, so it is kept. */
export interface Checkpoint {
ref: string
label: string
at: number
tree: string
}
export class Snapshots {
readonly gitdir: string
enabled = true
reason = ""
private ready = false
private bigExcluded = new Set<string>()
constructor(
readonly root: string,
readonly realGitRoot?: string,
private maxFiles = MAX_FILES,
) {
const id = createHash("sha1").update(root).digest("hex").slice(0, 16)
this.gitdir = join(paths.data, "snapshot", id)
}
private g(args: string[], input?: string) {
return git(this.root, ["--git-dir", this.gitdir, "--work-tree", this.root, "-c", "core.autocrlf=false", ...args], { input })
}
private init(): boolean {
if (this.ready) return this.enabled
this.ready = true
if (!existsSync(join(this.gitdir, "HEAD"))) {
mkdirSync(this.gitdir, { recursive: true })
const r = git(this.root, ["init", "-q", "--bare", this.gitdir])
if (r.code !== 0) return this.disable(`cannot create the snapshot store: ${r.err}`)
for (const [k, v] of [["core.autocrlf", "false"], ["core.fsmonitor", "false"], ["gc.auto", "0"], ["core.bare", "false"]] as const)
git(this.root, ["--git-dir", this.gitdir, "config", k, v])
}
// Borrow the project's own object store: files git already hashed are not hashed again.
if (this.realGitRoot) {
const common = git(this.realGitRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"])
if (common.code === 0 && existsSync(join(common.out, "objects"))) {
mkdirSync(join(this.gitdir, "objects", "info"), { recursive: true })
writeFileSync(join(this.gitdir, "objects", "info", "alternates"), join(common.out, "objects") + "\n")
}
}
this.writeExcludes()
const count = this.g(["ls-files", "--others", "--cached", "--exclude-standard", "-z"])
const n = count.out ? count.out.split("\0").filter(Boolean).length : 0
if (n > this.maxFiles) return this.disable(`${n} files — too many to snapshot (limit ${this.maxFiles}); /undo is off here`)
return true
}
private disable(reason: string): false {
this.enabled = false
this.reason = reason
return false
}
private writeExcludes() {
const lines = ["/.git", ...LOCAL_DIRS.map((d) => `/${d}`), "node_modules/", ...[...this.bigExcluded].map((p) => `/${p}`)]
if (this.realGitRoot) {
const own = join(this.realGitRoot, ".git", "info", "exclude")
if (existsSync(own)) lines.push(readFileSync(own, "utf8"))
}
mkdirSync(join(this.gitdir, "info"), { recursive: true })
writeFileSync(join(this.gitdir, "info", "exclude"), lines.join("\n") + "\n")
}
/** Keep new large files (logs, builds, media) out: a snapshot store is not a backup. */
private excludeBig() {
const others = this.g(["ls-files", "--others", "--exclude-standard", "-z"])
let added = false
for (const p of others.out.split("\0").filter(Boolean)) {
try {
if (statSync(join(this.root, p)).size > MAX_FILE_BYTES && !this.bigExcluded.has(p)) {
this.bigExcluded.add(p)
added = true
}
} catch {}
}
if (added) this.writeExcludes()
}
/** The working tree now, as a tree hash; undefined when snapshots are off. */
track(): string | undefined {
if (!this.init()) return undefined
this.excludeBig()
const add = this.g(["add", "--all", "--", "."])
if (add.code !== 0) {
this.disable(`snapshot failed: ${add.err.split("\n")[0]}`)
return undefined
}
const tree = this.g(["write-tree"])
return tree.code === 0 ? tree.out : undefined
}
changed(from: string, to: string): string[] {
const r = this.g(["diff", "--name-only", "--no-renames", "-z", from, to, "--", "."])
return r.code === 0 ? r.out.split("\0").filter(Boolean) : []
}
/** Unified diff between two trees (or from a tree to the working tree when `to` is omitted). */
diff(from: string, to?: string): string {
const r = this.g(["diff", "--no-ext-diff", "--no-renames", "--no-color", from, ...(to ? [to] : []), "--", "."])
return r.code === 0 ? r.out : ""
}
/** Save the working tree as it is now under a name. Its objects are copied into the shadow
* store: the project's own store (borrowed for speed) may drop them in a gc, and a checkpoint
* can be weeks old by the time it is wanted. */
checkpoint(label: string): Checkpoint | undefined {
const tree = this.track()
if (!tree) return undefined
const at = Date.now()
const c = this.g(["-c", "user.name=lembas", "-c", "user.email=lembas@localhost", "commit-tree", tree, "-m", label])
if (c.code !== 0) return undefined
const ref = `refs/checkpoints/${at.toString(36)}${Math.random().toString(36).slice(2, 5)}`
if (this.g(["update-ref", ref, c.out]).code !== 0) return undefined
this.g(["pack-objects", "--revs", "--quiet", join(this.gitdir, "objects", "pack", "pack")], `${c.out}\n`)
return { ref, label, at, tree }
}
/** Checkpoints of this project, newest first. */
checkpoints(): Checkpoint[] {
if (!this.init()) return []
const r = this.g(["for-each-ref", "--sort=-creatordate", "--format=%(refname)%00%(tree)%00%(creatordate:unix)%00%(contents:subject)", "refs/checkpoints/"])
if (r.code !== 0 || !r.out) return []
// The ref's name carries the time to the millisecond (git's dates stop at the second).
return r.out
.split("\n")
.map((l) => {
const [ref = "", tree = "", at = "0", label = ""] = l.split("\0")
const ms = Number.parseInt(ref.slice("refs/checkpoints/".length, -3), 36)
return { ref, tree, at: Number.isFinite(ms) ? ms : Number(at) * 1000, label }
})
.sort((a, b) => b.at - a.at)
}
dropCheckpoint(ref: string): boolean {
return ref.startsWith("refs/checkpoints/") && this.g(["update-ref", "-d", ref]).code === 0
}
/** Put the working tree back as it was at a checkpoint — only the files that differ. What was
* there is saved first as a checkpoint of its own, so a restore can itself be undone. */
restoreCheckpoint(cp: Checkpoint): { files: string[]; saved?: Checkpoint } | undefined {
const saved = this.checkpoint(`before going back to "${cp.label}"`)
if (!saved) return undefined
const files = this.changed(cp.tree, saved.tree)
if (!files.length) {
this.dropCheckpoint(saved.ref)
return { files }
}
return { files: this.restore(cp.tree, files), saved }
}
/** Put `files` back as they are in `tree`; a file the tree does not have is deleted. */
restore(tree: string, files: string[]): string[] {
const done: string[] = []
for (const f of files) {
const has = this.g(["ls-tree", "--name-only", tree, "--", f]).out.trim() !== ""
if (has) {
const r = this.g(["checkout", tree, "--", f])
if (r.code === 0) done.push(f)
} else {
rmSync(join(this.root, f), { force: true })
done.push(f)
}
}
return done
}
}
+57
View File
@@ -0,0 +1,57 @@
// Worktrees for subagents: a subagent that changes files can work in a checkout of its own, on a
// branch of its own, so two of them (or one and you) never edit the same files at once. When it
// is done its changes are committed on that branch and the checkout goes; the branch stays for
// you to merge or delete. A worktree starts from HEAD: uncommitted work is not in it.
import { mkdirSync } from "node:fs"
import { join } from "node:path"
import { createHash } from "node:crypto"
import { paths } from "../config/paths.ts"
import { git } from "./run.ts"
export interface Worktree {
dir: string
branch: string
base: string
}
export function createWorktree(repo: string, label: string): Worktree {
const id = Math.random().toString(36).slice(2, 8)
const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 30) || "agent"
const branch = `lembas/${slug}-${id}`
const dir = join(paths.data, "worktrees", createHash("sha256").update(repo).digest("hex").slice(0, 12), `${slug}-${id}`)
mkdirSync(join(dir, ".."), { recursive: true })
// Records of checkouts whose directories are gone (a crash, a manual rm) go first.
git(repo, ["worktree", "prune"])
const base = git(repo, ["rev-parse", "HEAD"]).out
if (!base) throw new Error("the repository has no commit yet, so there is nothing to branch from")
const r = git(repo, ["worktree", "add", "-q", "-b", branch, dir, base])
if (r.code) throw new Error(`git worktree add: ${r.err}`)
return { dir, branch, base }
}
export interface WorktreeResult {
/** What to tell the model and the user. */
summary: string
kept: boolean
}
/** Commit what the subagent left, remove the checkout, and keep the branch only if it has work. */
export function finishWorktree(repo: string, wt: Worktree, message: string): WorktreeResult {
// The checkout is the subagent's own, so everything in it is its work.
git(wt.dir, ["add", "-A"])
if (git(wt.dir, ["diff", "--cached", "--quiet"]).code !== 0) {
const c = git(wt.dir, ["commit", "-q", "-m", message])
if (c.code) return { summary: `Its changes could not be committed (${c.err || c.out}); they are still in ${wt.dir} on branch ${wt.branch}.`, kept: true }
}
const commits = Number(git(repo, ["rev-list", "--count", `${wt.base}..${wt.branch}`]).out || "0")
git(repo, ["worktree", "remove", "--force", wt.dir])
if (!commits) {
git(repo, ["branch", "-D", wt.branch])
return { summary: "It changed nothing, so its branch was removed.", kept: false }
}
const stat = git(repo, ["diff", "--shortstat", wt.base, wt.branch]).out.trim()
return {
summary: `Its work is on branch ${wt.branch} (${commits} commit${commits > 1 ? "s" : ""}: ${stat}), not in your working tree. Look with \`git diff HEAD...${wt.branch}\`; take it with \`git merge ${wt.branch}\`; drop it with \`git branch -D ${wt.branch}\`.`,
kept: true,
}
}