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:
355 files changed
+47028
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user