From 42afdbfe44622024b38c909d4b951622241b9816 Mon Sep 17 00:00:00 2001 From: Nivesh353 Date: Wed, 9 Sep 2026 13:03:16 +0530 Subject: [PATCH 1/2] feat(version): tag, diff and roll back an agent's config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent is already a git repo, so a known-good configuration is just a tag — but restoring one meant hand-running git plumbing and knowing which paths are safe to touch. Adds a CLI over those primitives; no new storage. - gitagent version save/list/show/diff/rollback, dispatched like `plugin` before parseArgs. Versions are annotated tags under refs/tags/agentcfg/, namespaced so they never collide with a repo's release tags. - Scoped to config: agent.yaml, SOUL.md, RULES.md, DUTIES.md, AGENTS.md, config/, tools/, hooks/, knowledge/, examples/, compliance/, agents/, workflows/, schedules/, plugins/. memory/ and skills/ are deliberately excluded — memory commits on every save (src/tools/memory.ts) and skill_learner rewrites skills/ at runtime, so those commits interleave with config commits and restoring them would destroy what the agent learned after the tag was cut. - Rollback is forward-only: it writes a new commit rather than rewriting history, so the rollback is itself visible in git log and revertible. No reset --hard anywhere. - Restore is planned from `git diff --name-status --no-renames -z HEAD`, then remove-before-restore. A plain `git checkout -- tools/` leaves files added after the tag in place, which yields a merge of old and new config rather than a rollback; the ordering also handles file<->directory flips. test/version.test.ts covers both. - Adds src/git.ts: every call is execFileSync with an argv array, so quoting and injection bugs are structurally impossible. Also handles --no-pager, a 64MiB buffer, closed stdin, and per-invocation identity/no-gpgsign so commits work in a repo with no user.email without touching git config. Only isGitRepo is migrated here; porting the other 13 execSync sites is a separate change. - Establishes the temp-git-repo test fixture the suite lacked (hermetic via GIT_CONFIG_GLOBAL=/dev/null). 29 new cases, full suite 94 passing. --- Documentation.md | 77 ++++++++ README.md | 53 ++++- src/git.ts | 137 +++++++++++++ src/index.ts | 53 +++-- src/version-cli.ts | 270 +++++++++++++++++++++++++ src/version.ts | 461 +++++++++++++++++++++++++++++++++++++++++++ test/git.test.ts | 151 ++++++++++++++ test/version.test.ts | 363 ++++++++++++++++++++++++++++++++++ 8 files changed, 1543 insertions(+), 22 deletions(-) create mode 100644 src/git.ts create mode 100644 src/version-cli.ts create mode 100644 src/version.ts create mode 100644 test/git.test.ts create mode 100644 test/version.test.ts diff --git a/Documentation.md b/Documentation.md index c795966..6907e01 100644 --- a/Documentation.md +++ b/Documentation.md @@ -19,6 +19,7 @@ - [Workflows & SkillFlows](#workflows--skillflows) - [Hooks](#hooks) - [Plugins](#plugins) +- [Versioning & Rollback](#version-cli) - [Memory System](#memory-system) - [Schedules & Cron](#schedules--cron) - [Integrations](#integrations) @@ -121,6 +122,10 @@ gitagent --model anthropic:claude-opus-4-6 --voice --dir ~/assistant | `--repo` | `-r` | Clone and work on remote repository | — | | `--pat` | — | GitHub/GitLab personal access token | `GITHUB_TOKEN` env | | `--session` | — | Git branch name for session isolation | auto-generated | +| `--version` | — | Print the gitagent version (`-v` is taken by `--voice`) | — | + +> `plugin` and `version` are subcommands, so a prompt starting with either word must be +> quoted or passed via `-p`: `gitagent -p "version this repo"`. ### REPL Commands @@ -146,6 +151,78 @@ gitagent plugin remove my-plugin --dir ~/assistant gitagent plugin init my-plugin --dir ~/assistant ``` +### Version CLI + +Snapshot, inspect and restore an agent's configuration. Versions are annotated git tags under +`refs/tags/agentcfg/`, so the storage is the agent's own repo — nothing extra on disk. + +```bash +gitagent version save v1.0 -m "baseline" # tag current config +gitagent version save # name defaults to v +gitagent version save v1.1 --commit # commit dirty config first, then tag +gitagent version list --json +gitagent version show v1.0 --files +gitagent version diff v1.0 # against the working tree +gitagent version diff v1.0 v1.1 --stat +gitagent version rollback v1.0 --dry-run +gitagent version rollback v1.0 --yes +``` + +| Subcommand | Description | +|------------|-------------| +| `save []` | Tag the current config. Aliases: `tag` | +| `list` | List saved versions, newest first. Aliases: `ls` | +| `show ` | Version metadata, file inventory, drift since | +| `diff []` | Config diff between versions or against the working tree | +| `rollback ` | Restore config from a version as a new commit. Aliases: `restore` | + +| Flag | Applies to | Description | +|------|-----------|-------------| +| `-m`, `--message` | `save`, `rollback` | Tag or commit message | +| `--commit` | `save` | Commit uncommitted config before tagging | +| `--force` | `save` | Move an existing tag | +| `--force` | `rollback` | Discard local config changes; allow detached HEAD | +| `--dry-run` | `rollback` | Print the plan, change nothing | +| `--yes` | `rollback` | Skip confirmation (required when stdin is not a TTY) | +| `--json` | `list` | Machine-readable output | +| `--files` | `show` | List every file rather than the first 20 | +| `--stat`, `--name-only` | `diff` | Passed through to `git diff` | +| `--dir`, `-d` | all | Agent directory (default: cwd) | + +**What is versioned.** Only configuration: + +``` +agent.yaml SOUL.md RULES.md DUTIES.md AGENTS.md +config/ tools/ hooks/ knowledge/ examples/ +compliance/ agents/ workflows/ schedules/ plugins/ +``` + +**What is never touched.** `memory/` and `skills/`. The agent commits to `memory/` on every save and +`skill_learner` rewrites `skills/` at runtime, so those commits interleave with config commits +throughout history. Restoring them would silently destroy knowledge the agent gained after the tag +was cut. A rollback therefore reverts *how the agent is configured*, never *what it has learned*. + +**How rollback works.** It diffs the tag against `HEAD` scoped to the config paths, deletes files +added since, restores files that changed or were removed, and records the result as a **new commit**. +History is never rewritten — no `reset --hard`, no branch switching — so a rollback is itself visible +in `git log` and can be reverted like any other commit. + +**Edge-case behavior.** + +| Situation | Behavior | +|-----------|----------| +| Uncommitted config changes | `save` refuses (offers `--commit`); `rollback` refuses unless `--force`. Dirty `memory/` never blocks either | +| Staged changes in the index | Refused, so unrelated staged files can't ride along in the commit | +| Detached HEAD | `rollback` refuses unless `--force` | +| No git identity configured | An identity is injected per-invocation; your git config is never modified | +| Rollback with nothing to change | Reports "already at" and creates no empty commit | +| Agent in a repo subdirectory | Supported; only the agent's own paths are versioned | +| Shallow clone (`--repo` mode) | Warns that tags may be missing — `git fetch --unshallow --tags` | + +**Known limitations.** Tags are repo-global, so two agents sharing one repo share the `agentcfg/` +namespace. Gitignored config files are not captured. Pushing tags is manual — +`git push origin refs/tags/agentcfg/v1.0`. + --- ## Agent Configuration diff --git a/README.md b/README.md index 7d0257f..35f67dd 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ ToolsHooksSkills • - Plugins + Plugins • + Versioning

--- @@ -136,6 +137,10 @@ gitagent --repo https://github.com/org/repo "Add unit tests" | `--sandbox` | `-s` | Run in sandbox VM | | `--prompt ` | `-p` | Single-shot prompt (skip REPL) | | `--env ` | `-e` | Environment config | +| `--version` | | Print the gitagent version (`-v` is `--voice`) | + +`plugin` and `version` are subcommands, so a prompt starting with either word needs +`-p` or quotes: `gitagent -p "version this repo"`. ### SDK @@ -607,6 +612,52 @@ my-plugin/ └── index.ts # Programmatic entry point ``` +## Versioning & Rollback + +Because the agent is a git repo, a "version" is just a tag. `gitagent version` wraps that so you can +snapshot a known-good configuration, see what drifted, and restore it — without hand-running git. + +```bash +gitagent version save v1.0 -m "baseline before model swap" +gitagent version list +gitagent version show v1.0 +gitagent version diff v1.0 # v1.0 vs working tree +gitagent version diff v1.0 v1.1 # version vs version +gitagent version rollback v1.0 --dry-run +gitagent version rollback v1.0 +``` + +| Command | Description | +|---|---| +| `save []` | Tag the current config. Name defaults to `v` + `version` from `agent.yaml` | +| `list` | List saved versions (`--json` for scripting) | +| `show ` | Version details, file inventory, and what changed since | +| `diff []` | Config diff between versions, or against the working tree | +| `rollback ` | Restore config from a version as a new commit | + +| Flag | Applies to | Description | +|---|---|---| +| `-m ` | `save`, `rollback` | Tag / commit message | +| `--commit` | `save` | Commit uncommitted config before tagging | +| `--force` | `save`, `rollback` | Move an existing tag / discard local config changes | +| `--dry-run` | `rollback` | Show the plan without changing anything | +| `--yes` | `rollback` | Skip the confirmation prompt (required when not a TTY) | +| `--json` | `list` | Machine-readable output | + +**Three things worth knowing:** + +- **Rollback restores config only.** `memory/` and `skills/` are never modified. The agent commits to + `memory/` on every save and rewrites `skills/` as it learns, so those commits interleave with config + commits — restoring them wholesale would destroy everything the agent learned after the tag was cut. + Versioned paths are: `agent.yaml`, `SOUL.md`, `RULES.md`, `DUTIES.md`, `AGENTS.md`, `config/`, + `tools/`, `hooks/`, `knowledge/`, `examples/`, `compliance/`, `agents/`, `workflows/`, `schedules/`, + `plugins/`. +- **Rollback is forward-only.** It writes a new commit rather than rewriting history, so nothing is + lost and the rollback itself shows up in `git log`. No `reset --hard`, ever. +- **Versions are local git tags** under `agentcfg/`, namespaced so they never collide with a repo's + release tags. They stay local until you push them: + `git push origin refs/tags/agentcfg/v1.0`. + ## MCP (Model Context Protocol) Gitagent is an **MCP client**: point it at any [MCP server](https://modelcontextprotocol.io) and that server's tools are automatically discovered and made available to the agent — no integration code to write. This unlocks the whole ecosystem of ready-made servers (filesystem, GitHub, Postgres, Slack, fetch, …). diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..89db28c --- /dev/null +++ b/src/git.ts @@ -0,0 +1,137 @@ +import { execFileSync, spawnSync } from "child_process"; + +// A real config-tree diff blows past Node's 1 MiB default and throws ENOBUFS. +const MAX_BUFFER = 64 * 1024 * 1024; + +export class GitError extends Error { + constructor( + message: string, + readonly args: readonly string[], + readonly status: number, + readonly stderr: string, + ) { + super(message); + this.name = "GitError"; + } +} + +function gitEnv(): NodeJS.ProcessEnv { + return { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" }; +} + +/** + * Every git call in this module goes through here: an argv array, never an + * interpolated string, so no caller can produce a shell-injection or quoting bug. + * `--no-pager` keeps `diff` from launching `less` and appearing to hang; stdin is + * closed so git can never block on a credential or editor prompt. + */ +function run(cwd: string, args: string[]): string { + try { + return execFileSync("git", ["--no-pager", ...args], { + cwd, + env: gitEnv(), + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + maxBuffer: MAX_BUFFER, + }); + } catch (err: any) { + const status = typeof err?.status === "number" ? err.status : -1; + const stderr = typeof err?.stderr === "string" ? err.stderr.trim() : ""; + throw new GitError(stderr || err?.message || `git ${args.join(" ")} failed`, args, status, stderr); + } +} + +/** Trimmed stdout. Throws GitError on non-zero exit. */ +export function git(cwd: string, args: string[]): string { + return run(cwd, args).trim(); +} + +/** Raw stdout, NOT trimmed — required for `-z` NUL-separated plumbing output. */ +export function gitRaw(cwd: string, args: string[]): string { + return run(cwd, args); +} + +/** Trimmed stdout, or null on any failure. Never throws. */ +export function gitTry(cwd: string, args: string[]): string | null { + try { + return run(cwd, args).trim(); + } catch { + return null; + } +} + +/** True iff git exited 0. For predicate commands like `diff --quiet`. */ +export function gitOk(cwd: string, args: string[]): boolean { + try { + run(cwd, args); + return true; + } catch { + return false; + } +} + +/** Trimmed stdout split into non-empty lines. */ +export function gitLines(cwd: string, args: string[]): string[] { + return run(cwd, args) + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); +} + +/** + * Identity is injected per-invocation, never written to the user's config, and only + * when the repo has none — `ensureRepo` leaves repos without one and `commit`/`tag -a` + * hard-fail in that state. Signing is disabled unconditionally: a user with global + * `commit.gpgsign=true` would otherwise hit a pinentry prompt with stdin closed. + */ +function identityArgs(cwd: string): string[] { + const args = ["-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"]; + if (!gitTry(cwd, ["config", "--get", "user.email"])) { + args.push("-c", "user.name=GitAgent", "-c", "user.email=gitagent@localhost"); + } + return args; +} + +/** Mutating operations (commit, tag, rm, checkout). Throws GitError. */ +export function gitWrite(cwd: string, args: string[]): string { + return run(cwd, [...identityArgs(cwd), ...args]).trim(); +} + +/** Streams git's own stdout/stderr through, preserving colour. Returns the exit status. */ +export function gitStream(cwd: string, args: string[]): number { + const result = spawnSync("git", ["--no-pager", ...args], { + cwd, + env: gitEnv(), + stdio: ["ignore", "inherit", "inherit"], + }); + return result.status ?? -1; +} + +export function isGitRepo(cwd: string): boolean { + return gitOk(cwd, ["rev-parse", "--is-inside-work-tree"]); +} + +export function isBareRepo(cwd: string): boolean { + return gitTry(cwd, ["rev-parse", "--is-bare-repository"]) === "true"; +} + +export function isShallowRepo(cwd: string): boolean { + return gitTry(cwd, ["rev-parse", "--is-shallow-repository"]) === "true"; +} + +export function hasCommits(cwd: string): boolean { + return gitOk(cwd, ["rev-parse", "--verify", "-q", "HEAD"]); +} + +export function repoRoot(cwd: string): string { + return git(cwd, ["rev-parse", "--show-toplevel"]); +} + +/** "" at the repo root, "bot/" when cwd is a subdirectory. */ +export function repoPrefix(cwd: string): string { + return git(cwd, ["rev-parse", "--show-prefix"]); +} + +export function refExists(cwd: string, ref: string): boolean { + return gitOk(cwd, ["rev-parse", "--verify", "-q", `${ref}^{}`]); +} diff --git a/src/index.ts b/src/index.ts index ba4eeb2..a5291ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,8 @@ import type { LocalSession } from "./session.js"; // Imported dynamically below so the slim core has no static dependency on it — // users without voice get a clean install + a clear error if they try --voice. import { handlePluginCommand } from "./plugin-cli.js"; +import { handleVersionCommand, printToolVersion } from "./version-cli.js"; +import { isGitRepo } from "./git.js"; import { context as otelContext } from "@opentelemetry/api"; import { initTelemetry, @@ -127,6 +129,20 @@ function parseArgs(argv: string[]): ParsedArgs { return { model, dir, prompt, env, sandbox, sandboxRepo, sandboxToken, repo, pat, session, voice }; } +/** Pull --dir/-d out of a subcommand's args; subcommands don't use parseArgs. */ +function extractDirFlag(args: string[]): { agentDir: string; rest: string[] } { + let agentDir = process.cwd(); + const rest: string[] = []; + for (let i = 0; i < args.length; i++) { + if ((args[i] === "--dir" || args[i] === "-d") && args[i + 1]) { + agentDir = args[++i]; + } else { + rest.push(args[i]); + } + } + return { agentDir: resolve(agentDir), rest }; +} + function handleEvent( event: AgentEvent, hooksConfig: HooksConfig | null, @@ -197,15 +213,6 @@ function summarizeArgs(args: any): string { .join(", "); } -function isGitRepo(dir: string): boolean { - try { - execSync("git rev-parse --is-inside-work-tree", { cwd: dir, stdio: "pipe" }); - return true; - } catch { - return false; - } -} - async function fileExists(path: string): Promise { try { await access(path); @@ -305,19 +312,23 @@ async function ensureRepo(dir: string, model?: string): Promise { } async function main(): Promise { - // Handle plugin subcommand: gitagent plugin + // Subcommands are dispatched before parseArgs, since parseArgs treats any bare + // word as the prompt. if (process.argv[2] === "plugin") { - const allArgs = process.argv.slice(3); - let agentDir = process.cwd(); - const pluginArgs: string[] = []; - for (let i = 0; i < allArgs.length; i++) { - if ((allArgs[i] === "--dir" || allArgs[i] === "-d") && allArgs[i + 1]) { - agentDir = allArgs[++i]; - } else { - pluginArgs.push(allArgs[i]); - } - } - await handlePluginCommand(resolve(agentDir), pluginArgs); + const { agentDir, rest } = extractDirFlag(process.argv.slice(3)); + await handlePluginCommand(agentDir, rest); + return; + } + + // gitagent version + if (process.argv[2] === "version") { + const { agentDir, rest } = extractDirFlag(process.argv.slice(3)); + await handleVersionCommand(agentDir, rest); + return; + } + + if (process.argv.includes("--version")) { + printToolVersion(); return; } diff --git a/src/version-cli.ts b/src/version-cli.ts new file mode 100644 index 0000000..6947f1d --- /dev/null +++ b/src/version-cli.ts @@ -0,0 +1,270 @@ +import { createInterface } from "readline"; +import { createRequire } from "module"; +import { gitStream } from "./git.js"; +import { + TAG_NS, + VersionError, + applyRollback, + assertRollbackReady, + buildDiffArgs, + hasConfigDiff, + isShallow, + listVersions, + planRollback, + resolveContext, + saveVersion, + showVersion, +} from "./version.js"; +import type { RollbackPlan, VersionContext } from "./version.js"; + +const require = createRequire(import.meta.url); +const { version: GITAGENT_VERSION } = require("../package.json"); + +const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; +const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; +const red = (s: string) => `\x1b[31m${s}\x1b[0m`; +const green = (s: string) => `\x1b[32m${s}\x1b[0m`; + +function flagValue(args: string[], ...names: string[]): string | undefined { + for (const name of names) { + const i = args.indexOf(name); + if (i !== -1 && args[i + 1]) return args[i + 1]; + } + return undefined; +} + +/** Positional args: everything that isn't a flag or a flag's value. */ +function positionals(args: string[], valued: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i++) { + if (valued.includes(args[i])) { + i++; + continue; + } + if (args[i].startsWith("-")) continue; + out.push(args[i]); + } + return out; +} + +function confirm(question: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(`${question} [y/N] `, (answer) => { + rl.close(); + resolve(/^y(es)?$/i.test(answer.trim())); + }); + }); +} + +function printPaths(paths: string[], limit = 20): void { + for (const p of paths.slice(0, limit)) console.log(` ${p}`); + if (paths.length > limit) console.log(dim(` …and ${paths.length - limit} more`)); +} + +function warnIfShallow(ctx: VersionContext): void { + if (isShallow(ctx)) { + console.log(dim("Note: shallow clone — some tags may be missing. git fetch --unshallow --tags")); + } +} + +// ── Subcommands ──────────────────────────────────────────────────────── + +async function handleSave(agentDir: string, args: string[]): Promise { + const ctx = resolveContext(agentDir); + const name = positionals(args, ["-m", "--message"])[0]; + const result = saveVersion(ctx, { + name, + message: flagValue(args, "-m", "--message"), + commit: args.includes("--commit"), + force: args.includes("--force"), + }); + + if (result.committed.length > 0) { + console.log(dim(`Committed ${result.committed.length} config file(s) first.`)); + } + console.log(`${green("Saved version")} ${bold(result.name)} ${dim(`(${result.commit})`)}`); + console.log(dim(`${result.fileCount} config files tracked · memory/ and skills/ not included`)); + console.log(dim(`Local only — push with: git push origin ${result.ref}`)); +} + +function handleList(agentDir: string, args: string[]): void { + const ctx = resolveContext(agentDir); + const versions = listVersions(ctx); + + if (args.includes("--json")) { + console.log(JSON.stringify(versions, null, 2)); + return; + } + if (versions.length === 0) { + console.log(dim("No versions saved yet. Create one with: gitagent version save v1.0")); + return; + } + const width = Math.max(...versions.map((v) => v.name.length)); + for (const v of versions) { + const meta = dim(`${v.date.slice(0, 10)} ${v.commit}`); + console.log(` ${bold(v.name.padEnd(width))} ${meta} ${v.subject}`); + } + warnIfShallow(ctx); +} + +function handleShow(agentDir: string, args: string[]): void { + const ctx = resolveContext(agentDir); + const name = positionals(args, [])[0]; + if (!name) { + console.error(red("Usage: gitagent version show [--files]")); + process.exit(1); + } + const info = showVersion(ctx, name); + + console.log(`${bold(info.name)} ${dim(`(${info.commit})`)}`); + if (info.message) console.log(info.message); + console.log(dim(`${info.author} · ${info.date}`)); + console.log(); + console.log(dim(`${info.files.length} config file(s) at this version:`)); + printPaths(info.files, args.includes("--files") ? info.files.length : 20); + console.log(); + if (info.driftStat) { + console.log(dim("Changes since this version:")); + console.log(info.driftStat); + console.log(dim(`Full patch: gitagent version diff ${info.name}`)); + } else { + console.log(dim("Current config matches this version.")); + } + warnIfShallow(ctx); +} + +function handleDiff(agentDir: string, args: string[]): void { + const ctx = resolveContext(agentDir); + const [a, b] = positionals(args, []); + if (!a) { + console.error(red("Usage: gitagent version diff [] [--stat] [--name-only]")); + process.exit(1); + } + const extra: string[] = []; + if (args.includes("--stat")) extra.push("--stat"); + if (args.includes("--name-only")) extra.push("--name-only"); + + if (!hasConfigDiff(ctx, a, b)) { + console.log(dim("No config differences.")); + return; + } + console.log(dim(`${a} → ${b ?? "working tree"}`)); + gitStream(ctx.root, buildDiffArgs(ctx, a, b, extra)); +} + +function printPlan(plan: RollbackPlan): void { + if (plan.restore.length > 0) { + console.log(dim(`Restore ${plan.restore.length} file(s):`)); + printPaths(plan.restore); + } + if (plan.remove.length > 0) { + console.log(dim(`Remove ${plan.remove.length} file(s) added after the version:`)); + printPaths(plan.remove); + } +} + +async function handleRollback(agentDir: string, args: string[]): Promise { + const ctx = resolveContext(agentDir); + const name = positionals(args, ["-m", "--message"])[0]; + if (!name) { + console.error(red("Usage: gitagent version rollback [--dry-run] [--yes] [--force]")); + process.exit(1); + } + const force = args.includes("--force"); + assertRollbackReady(ctx, force); + + const plan = planRollback(ctx, name); + if (plan.restore.length === 0 && plan.remove.length === 0) { + console.log(dim(`Already at ${name} — no config changes to apply.`)); + return; + } + + printPlan(plan); + if (args.includes("--dry-run")) { + console.log(dim("Dry run — nothing changed.")); + return; + } + + if (!args.includes("--yes")) { + if (!process.stdin.isTTY) { + console.error(red("Refusing to roll back without confirmation. Pass --yes to run non-interactively.")); + process.exit(1); + } + if (!(await confirm(`Roll back config to ${bold(name)}?`))) { + console.log(dim("Aborted.")); + return; + } + } + + const result = applyRollback(ctx, plan, { message: flagValue(args, "-m", "--message"), force }); + console.log(`${green("Rolled back config to")} ${bold(name)}`); + console.log( + dim(`restored ${result.restored} file(s) · removed ${result.removed} file(s) · new commit ${result.commit}`), + ); + console.log(dim("History preserved — this is a forward commit; memory/ and skills/ untouched.")); +} + +function printHelp(): void { + console.log(`gitagent ${GITAGENT_VERSION}\n`); + console.log(bold("gitagent version") + " — agent config versioning\n"); + console.log("Commands:"); + console.log(` ${bold("save")} [] Tag the current config as a version`); + console.log(` ${bold("list")} List saved versions`); + console.log(` ${bold("show")} Show a version and what changed since`); + console.log(` ${bold("diff")} [] Diff a version against another, or the working tree`); + console.log(` ${bold("rollback")} Restore config from a version as a new commit`); + console.log(); + console.log(dim("Rollback restores config only — memory/ and skills/ are never modified.")); + console.log(dim("Versions are git tags under " + TAG_NS + "/ and stay local until you push them.")); +} + +// ── Main CLI handler ─────────────────────────────────────────────────── + +export async function handleVersionCommand(agentDir: string, args: string[]): Promise { + const subcommand = args[0]; + const subArgs = args.slice(1); + + try { + switch (subcommand) { + case "save": + case "tag": + await handleSave(agentDir, subArgs); + break; + case "list": + case "ls": + handleList(agentDir, subArgs); + break; + case "show": + handleShow(agentDir, subArgs); + break; + case "diff": + handleDiff(agentDir, subArgs); + break; + case "rollback": + case "restore": + await handleRollback(agentDir, subArgs); + break; + case undefined: + printHelp(); + break; + default: + console.error(red(`Unknown version subcommand: "${subcommand}"`)); + console.error(dim(`If you meant a prompt, use: gitagent -p "version ${args.join(" ")}"`)); + console.error(); + printHelp(); + process.exit(1); + } + } catch (err) { + if (err instanceof VersionError) { + console.error(red(err.message)); + if (err.paths.length > 0) printPaths(err.paths); + process.exit(1); + } + throw err; + } +} + +export function printToolVersion(): void { + console.log(GITAGENT_VERSION); +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..6bd1a28 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,461 @@ +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import yaml from "js-yaml"; +import { + git, + gitOk, + gitRaw, + gitTry, + gitWrite, + hasCommits, + isBareRepo, + isGitRepo, + isShallowRepo, + refExists, + repoPrefix, + repoRoot, +} from "./git.js"; + +/** Tags live under refs/tags/agentcfg/ so agent versions never collide with a repo's release tags. */ +export const TAG_NS = "agentcfg"; + +/** + * What a version captures and what a rollback restores. + * + * memory/ and skills/ are deliberately absent: memory commits on every save + * (src/tools/memory.ts) and skill_learner rewrites skills/ at runtime, so restoring + * them would destroy knowledge the agent gained after the tag was cut. + * Keep in sync with the files loadAgent() reads in src/loader.ts. + */ +export const CONFIG_PATHS: readonly string[] = [ + "agent.yaml", + "SOUL.md", + "RULES.md", + "DUTIES.md", + "AGENTS.md", + "config", + "tools", + "hooks", + "knowledge", + "examples", + "compliance", + "agents", + "workflows", + "schedules", + "plugins", +]; + +export type VersionErrorCode = + | "NOT_A_REPO" + | "BARE_REPO" + | "NO_AGENT_YAML" + | "NO_COMMITS" + | "INVALID_NAME" + | "TAG_EXISTS" + | "UNKNOWN_VERSION" + | "DIRTY_CONFIG" + | "INDEX_DIRTY" + | "DETACHED_HEAD" + | "COMMIT_FAILED"; + +export class VersionError extends Error { + constructor( + readonly code: VersionErrorCode, + message: string, + readonly paths: string[] = [], + ) { + super(message); + this.name = "VersionError"; + } +} + +export interface VersionContext { + agentDir: string; + /** Repo root — every git command runs here so pathspecs are root-relative. */ + root: string; + /** "" when the agent is the repo root, "bot/" when it lives in a subdirectory. */ + prefix: string; + pathspecs: string[]; +} + +export interface VersionInfo { + name: string; + date: string; + commit: string; + subject: string; + annotated: boolean; +} + +export interface RollbackPlan { + name: string; + commit: string; + /** Present in the tag, changed or gone now — write the tag's content back. */ + restore: string[]; + /** Absent from the tag, present now — added after the tag, so delete. */ + remove: string[]; +} + +export interface RollbackResult { + changed: boolean; + restored: number; + removed: number; + commit?: string; +} + +export interface SaveResult { + name: string; + ref: string; + commit: string; + fileCount: number; + committed: string[]; +} + +export interface ShowResult { + name: string; + commit: string; + date: string; + author: string; + message: string; + files: string[]; + driftStat: string; +} + +export function tagRef(name: string): string { + return `refs/tags/${TAG_NS}/${name}`; +} + +// Stricter than git check-ref-format on purpose: forbidding "/" and whitespace is what +// makes the `for-each-ref --format=%(refname:strip=3)` parsing provably unambiguous. +const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export function validateVersionName(name: string): void { + const bad = (why: string) => { + throw new VersionError("INVALID_NAME", `Invalid version name "${name}": ${why}`); + }; + if (!name) bad("name is empty"); + if (name.length > 100) bad("name is longer than 100 characters"); + if (!NAME_RE.test(name)) { + bad("use letters, digits, dot, dash and underscore only, starting with a letter or digit"); + } + if (name.includes("..")) bad('name may not contain ".."'); + if (name.endsWith(".")) bad('name may not end with "."'); + if (name.endsWith(".lock")) bad('name may not end with ".lock"'); + if (name === "HEAD") bad('"HEAD" is reserved'); +} + +export function resolveContext(agentDir: string): VersionContext { + if (!isGitRepo(agentDir)) { + throw new VersionError( + "NOT_A_REPO", + `Not a git repository: ${agentDir}\nRun gitagent here once to initialize it, or pass --dir .`, + ); + } + if (isBareRepo(agentDir)) { + throw new VersionError("BARE_REPO", `Bare repository has no working tree: ${agentDir}`); + } + if (!existsSync(join(agentDir, "agent.yaml"))) { + throw new VersionError("NO_AGENT_YAML", `No agent.yaml in ${agentDir} — not a gitagent agent.`); + } + if (!hasCommits(agentDir)) { + throw new VersionError("NO_COMMITS", "Repository has no commits yet — nothing to version."); + } + const root = repoRoot(agentDir); + const prefix = repoPrefix(agentDir); + return { agentDir, root, prefix, pathspecs: CONFIG_PATHS.map((p) => prefix + p) }; +} + +export function isShallow(ctx: VersionContext): boolean { + return isShallowRepo(ctx.root); +} + +/** Split NUL-separated plumbing output, dropping the trailing empty field. */ +function nulFields(raw: string): string[] { + return raw.split("\0").filter((f) => f !== ""); +} + +/** Uncommitted config changes, including untracked files. Never reports memory/ or skills/. */ +export function dirtyConfigPaths(ctx: VersionContext): string[] { + const raw = gitRaw(ctx.root, ["status", "--porcelain", "-z", "--no-renames", "--", ...ctx.pathspecs]); + // Each record is "XY ": two status chars plus a space. + return nulFields(raw).map((rec) => rec.slice(3)); +} + +function assertIndexClean(ctx: VersionContext): void { + if (!gitOk(ctx.root, ["diff", "--cached", "--quiet"])) { + throw new VersionError( + "INDEX_DIRTY", + "The git index has staged changes. Commit or unstage them first —\n" + + "otherwise they would be swept into the commit this command creates.", + ); + } +} + +function readManifestVersion(agentDir: string): string | null { + try { + const doc = yaml.load(readFileSync(join(agentDir, "agent.yaml"), "utf-8")) as any; + const v = doc?.version; + return typeof v === "string" && v.trim() ? v.trim() : null; + } catch { + return null; + } +} + +export function listVersions(ctx: VersionContext): VersionInfo[] { + const format = [ + "%(refname:strip=3)", + "%(objecttype)", + "%(creatordate:iso-strict)", + "%(objectname:short)", + "%(*objectname:short)", + "%(contents:subject)", + ].join("%09"); + const raw = git(ctx.root, [ + "for-each-ref", + "--sort=-creatordate", + `--format=${format}`, + `refs/tags/${TAG_NS}/*`, + ]); + if (!raw) return []; + return raw.split("\n").map((line) => { + const parts = line.split("\t"); + // Subject is last so it can safely contain tabs. + const subject = parts.slice(5).join("\t"); + return { + name: parts[0], + annotated: parts[1] === "tag", + date: parts[2] ?? "", + // Annotated tags: prefer the dereferenced commit over the tag object. + commit: parts[4] || parts[3] || "", + subject, + }; + }); +} + +function assertVersionExists(ctx: VersionContext, name: string): void { + if (refExists(ctx.root, tagRef(name))) return; + const known = listVersions(ctx).map((v) => v.name); + const hint = known.length + ? `Known versions: ${known.join(", ")}` + : "No versions saved yet. Create one with: gitagent version save v1.0"; + throw new VersionError("UNKNOWN_VERSION", `Unknown version "${name}". ${hint}`); +} + +export interface SaveOptions { + name?: string; + message?: string; + commit?: boolean; + force?: boolean; +} + +export function saveVersion(ctx: VersionContext, opts: SaveOptions = {}): SaveResult { + let name = opts.name; + if (!name) { + const manifestVersion = readManifestVersion(ctx.agentDir); + if (!manifestVersion) { + throw new VersionError( + "INVALID_NAME", + "No version name given and agent.yaml has no version field.\nUsage: gitagent version save ", + ); + } + name = `v${manifestVersion.replace(/^v/, "")}`; + } + validateVersionName(name); + + assertIndexClean(ctx); + + if (refExists(ctx.root, tagRef(name)) && !opts.force) { + throw new VersionError( + "TAG_EXISTS", + `Version "${name}" already exists. Use --force to move it, or pick another name.`, + ); + } + + const dirty = dirtyConfigPaths(ctx); + let committed: string[] = []; + if (dirty.length > 0) { + if (!opts.commit) { + throw new VersionError( + "DIRTY_CONFIG", + `Uncommitted config changes would not be captured by "${name}".\n` + + "Re-run with --commit to commit them first, or commit them yourself.", + dirty, + ); + } + gitWrite(ctx.root, ["add", "--", ...dirty]); + // Guard against a pathspec surprise sweeping in something outside the allowlist. + const staged = gitRaw(ctx.root, ["diff", "--cached", "--name-only", "-z"]); + const stagedPaths = nulFields(staged); + const outside = stagedPaths.filter((p) => !dirty.includes(p)); + if (outside.length > 0) { + throw new VersionError( + "INDEX_DIRTY", + `Refusing to commit: unexpected staged paths outside the config allowlist.`, + outside, + ); + } + gitWrite(ctx.root, ["commit", "-m", `gitagent: snapshot config for ${name}`]); + committed = stagedPaths; + } + + const message = opts.message || `gitagent version ${name}`; + const tagArgs = ["tag", "-a"]; + if (opts.force) tagArgs.push("-f"); + gitWrite(ctx.root, [...tagArgs, `${TAG_NS}/${name}`, "-m", message, "HEAD"]); + + const commit = git(ctx.root, ["rev-parse", "--short", "HEAD"]); + const files = configFilesAt(ctx, name); + return { name, ref: tagRef(name), commit, fileCount: files.length, committed }; +} + +export function configFilesAt(ctx: VersionContext, name: string): string[] { + const raw = gitRaw(ctx.root, [ + "ls-tree", + "-r", + "--name-only", + "-z", + tagRef(name), + "--", + ...ctx.pathspecs, + ]); + return nulFields(raw); +} + +export function showVersion(ctx: VersionContext, name: string): ShowResult { + assertVersionExists(ctx, name); + const ref = tagRef(name); + const meta = git(ctx.root, ["show", "-s", "--format=%h%n%aI%n%an", `${ref}^{commit}`]).split("\n"); + const message = gitTry(ctx.root, ["for-each-ref", "--format=%(contents)", ref]) || ""; + return { + name, + commit: meta[0] ?? "", + date: meta[1] ?? "", + author: meta[2] ?? "", + message: message.trim(), + files: configFilesAt(ctx, name), + driftStat: gitTry(ctx.root, ["diff", "--stat", ref, "HEAD", "--", ...ctx.pathspecs]) || "", + }; +} + +/** Build the argv for a config-scoped diff. The pathspec suffix is what keeps memory/ out. */ +export function buildDiffArgs( + ctx: VersionContext, + a: string, + b: string | undefined, + extra: string[] = [], +): string[] { + assertVersionExists(ctx, a); + if (b) assertVersionExists(ctx, b); + const refs = b ? [tagRef(a), tagRef(b)] : [tagRef(a)]; + return ["diff", ...extra, ...refs, "--", ...ctx.pathspecs]; +} + +export function hasConfigDiff(ctx: VersionContext, a: string, b?: string): boolean { + return !gitOk(ctx.root, buildDiffArgs(ctx, a, b, ["--quiet"])); +} + +export function planRollback(ctx: VersionContext, name: string): RollbackPlan { + assertVersionExists(ctx, name); + const ref = tagRef(name); + // --no-renames is mandatory: rename records are 3 fields and would desynchronize + // the pairwise parse below. -z is mandatory so odd paths aren't C-quoted. + const raw = gitRaw(ctx.root, [ + "diff", + "--name-status", + "--no-renames", + "-z", + ref, + "HEAD", + "--", + ...ctx.pathspecs, + ]); + const fields = nulFields(raw); + const restore: string[] = []; + const remove: string[] = []; + for (let i = 0; i + 1 < fields.length; i += 2) { + const status = fields[i]; + const path = fields[i + 1]; + // Statuses are oriented tag -> HEAD. + if (status.startsWith("A")) remove.push(path); + else restore.push(path); + } + restore.sort(); + remove.sort(); + return { name, commit: git(ctx.root, ["rev-parse", "--short", `${ref}^{commit}`]), restore, remove }; +} + +export interface RollbackOptions { + message?: string; + force?: boolean; +} + +function chunk(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +export function applyRollback( + ctx: VersionContext, + plan: RollbackPlan, + opts: RollbackOptions = {}, +): RollbackResult { + if (plan.restore.length === 0 && plan.remove.length === 0) { + return { changed: false, restored: 0, removed: 0 }; + } + + // Remove before restore: if a config path was a file at the tag and is a directory + // now (or vice versa), checking out first fails with "Not a directory". + for (const paths of chunk(plan.remove, 500)) { + gitWrite(ctx.root, ["rm", "-q", "-f", "--", ...paths]); + } + for (const paths of chunk(plan.restore, 500)) { + gitWrite(ctx.root, ["checkout", tagRef(plan.name), "--", ...paths]); + } + + const subject = opts.message || `rollback config to ${plan.name}`; + const body = + `Restored ${plan.restore.length} file(s), removed ${plan.remove.length} file(s) ` + + `from ${TAG_NS}/${plan.name} (${plan.commit}).`; + try { + // git rm and git checkout -- both stage their work, and the index was + // verified clean beforehand, so a bare commit captures exactly this plan. + gitWrite(ctx.root, ["commit", "-m", subject, "-m", body]); + } catch (err: any) { + throw new VersionError( + "COMMIT_FAILED", + `Config was restored but the commit failed: ${err?.message ?? err}\n` + + "Changes are staged but not committed. Run 'git commit' to finish, " + + "or 'git reset --hard HEAD' to discard.", + ); + } + + return { + changed: true, + restored: plan.restore.length, + removed: plan.remove.length, + commit: git(ctx.root, ["rev-parse", "--short", "HEAD"]), + }; +} + +/** Preconditions shared by rollback before a plan is built. */ +export function assertRollbackReady(ctx: VersionContext, force: boolean): void { + assertIndexClean(ctx); + if (!force) { + const dirty = dirtyConfigPaths(ctx); + if (dirty.length > 0) { + throw new VersionError( + "DIRTY_CONFIG", + "Uncommitted config changes would be overwritten by this rollback.\n" + + "Commit them, or re-run with --force to discard them.", + dirty, + ); + } + } + if (!gitOk(ctx.root, ["symbolic-ref", "-q", "HEAD"]) && !force) { + throw new VersionError( + "DETACHED_HEAD", + "HEAD is detached — the rollback commit would not land on any branch.\n" + + "Check out a branch first, or re-run with --force.", + ); + } +} diff --git a/test/git.test.ts b/test/git.test.ts new file mode 100644 index 0000000..867b1b3 --- /dev/null +++ b/test/git.test.ts @@ -0,0 +1,151 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; + +let g: typeof import("../dist/git.js"); + +before(async () => { + // Hermetic git: ignore the developer's ~/.gitconfig so CI and laptops agree, and so + // the "repo has no identity" path is reachable on a machine that has a global one. + process.env.GIT_CONFIG_GLOBAL = "/dev/null"; + process.env.GIT_CONFIG_SYSTEM = "/dev/null"; + process.env.GIT_CONFIG_NOSYSTEM = "1"; + g = await import("../dist/git.js"); +}); + +// Raw git, deliberately not going through the module under test. +function raw(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, env: process.env, stdio: "pipe", encoding: "utf-8" }).trim(); +} + +async function makeRepo(identity = true): Promise { + const dir = await mkdtemp(join(tmpdir(), "gitagent-git-")); + raw(dir, "init", "-q", "-b", "main"); + if (identity) { + raw(dir, "config", "user.email", "t@example.com"); + raw(dir, "config", "user.name", "Test"); + } + return dir; +} + +describe("git helpers", () => { + it("detects repos, bareness and commits", async () => { + const plain = await mkdtemp(join(tmpdir(), "gitagent-plain-")); + assert.equal(g.isGitRepo(plain), false); + + const dir = await makeRepo(); + assert.equal(g.isGitRepo(dir), true); + assert.equal(g.isBareRepo(dir), false); + assert.equal(g.hasCommits(dir), false); + + g.gitWrite(dir, ["commit", "--allow-empty", "-m", "first"]); + assert.equal(g.hasCommits(dir), true); + + await rm(plain, { recursive: true, force: true }); + await rm(dir, { recursive: true, force: true }); + }); + + it("returns trimmed stdout", async () => { + const dir = await makeRepo(); + g.gitWrite(dir, ["commit", "--allow-empty", "-m", "first"]); + assert.equal(g.git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]), "main"); + await rm(dir, { recursive: true, force: true }); + }); + + it("reports failure as GitError, null and false depending on the call", async () => { + const dir = await makeRepo(); + g.gitWrite(dir, ["commit", "--allow-empty", "-m", "first"]); + + assert.throws( + () => g.git(dir, ["rev-parse", "--verify", "refs/tags/nope"]), + (err: any) => err.name === "GitError" && err.status !== 0 && typeof err.stderr === "string", + ); + assert.equal(g.gitTry(dir, ["rev-parse", "--verify", "refs/tags/nope"]), null); + assert.equal(g.gitOk(dir, ["rev-parse", "--verify", "refs/tags/nope"]), false); + + await rm(dir, { recursive: true, force: true }); + }); + + // The reason this module exists: src/tools/memory.ts and src/session.ts build git + // commands by string interpolation, so a quote in a commit message escapes the command. + it("passes shell metacharacters through verbatim", async () => { + const dir = await makeRepo(); + const evil = 'evil"; touch pwned; echo "'; + g.gitWrite(dir, ["commit", "--allow-empty", "-m", evil]); + + assert.equal(raw(dir, "log", "-1", "--format=%s"), evil); + assert.equal(existsSync(join(dir, "pwned")), false); + assert.equal(existsSync(join(process.cwd(), "pwned")), false); + + await rm(dir, { recursive: true, force: true }); + }); + + it("handles paths containing quotes and command substitution", async () => { + const dir = await makeRepo(); + const weird = 'we"ird $(touch owned).txt'; + await writeFile(join(dir, weird), "x", "utf-8"); + g.gitWrite(dir, ["add", "--", weird]); + g.gitWrite(dir, ["commit", "-m", "add weird"]); + + // -z output is unquoted; git C-quotes such paths in human-readable output. + assert.deepEqual(g.gitRaw(dir, ["ls-files", "-z"]).split("\0").filter(Boolean), [weird]); + assert.equal(existsSync(join(dir, "owned")), false); + + await rm(dir, { recursive: true, force: true }); + }); + + it("injects an identity only when the repo has none", async () => { + const anon = await makeRepo(false); + g.gitWrite(anon, ["commit", "--allow-empty", "-m", "x"]); + assert.equal(raw(anon, "log", "-1", "--format=%ae"), "gitagent@localhost"); + + const owned = await makeRepo(true); + g.gitWrite(owned, ["commit", "--allow-empty", "-m", "x"]); + assert.equal(raw(owned, "log", "-1", "--format=%ae"), "t@example.com"); + + await rm(anon, { recursive: true, force: true }); + await rm(owned, { recursive: true, force: true }); + }); + + it("gitRaw preserves NUL separators that gitLines would destroy", async () => { + const dir = await makeRepo(); + await writeFile(join(dir, "a.txt"), "a", "utf-8"); + await writeFile(join(dir, "b.txt"), "b", "utf-8"); + g.gitWrite(dir, ["add", "-A"]); + g.gitWrite(dir, ["commit", "-m", "two files"]); + + const out = g.gitRaw(dir, ["ls-tree", "-r", "--name-only", "-z", "HEAD"]); + assert.ok(out.includes("\0")); + assert.deepEqual(out.split("\0").filter(Boolean), ["a.txt", "b.txt"]); + + await rm(dir, { recursive: true, force: true }); + }); + + it("reports the repo root and the subdirectory prefix", async () => { + const dir = await makeRepo(); + g.gitWrite(dir, ["commit", "--allow-empty", "-m", "first"]); + const sub = join(dir, "bot"); + await mkdir(sub, { recursive: true }); + + assert.equal(g.repoPrefix(dir), ""); + assert.equal(g.repoPrefix(sub), "bot/"); + assert.equal(g.repoRoot(sub), g.repoRoot(dir)); + + await rm(dir, { recursive: true, force: true }); + }); + + it("resolves tag refs through refExists", async () => { + const dir = await makeRepo(); + g.gitWrite(dir, ["commit", "--allow-empty", "-m", "first"]); + g.gitWrite(dir, ["tag", "-a", "agentcfg/v1", "-m", "msg"]); + + assert.equal(g.refExists(dir, "refs/tags/agentcfg/v1"), true); + assert.equal(g.refExists(dir, "refs/tags/agentcfg/v2"), false); + + await rm(dir, { recursive: true, force: true }); + }); +}); diff --git a/test/version.test.ts b/test/version.test.ts new file mode 100644 index 0000000..9d70c78 --- /dev/null +++ b/test/version.test.ts @@ -0,0 +1,363 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; + +let v: typeof import("../dist/version.js"); + +before(async () => { + process.env.GIT_CONFIG_GLOBAL = "/dev/null"; + process.env.GIT_CONFIG_SYSTEM = "/dev/null"; + process.env.GIT_CONFIG_NOSYSTEM = "1"; + v = await import("../dist/version.js"); +}); + +function raw(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, env: process.env, stdio: "pipe", encoding: "utf-8" }).trim(); +} + +const MANIFEST = [ + 'spec_version: "0.1.0"', + "name: demo", + "version: 1.0.0", + "description: demo agent", + "model:", + ' preferred: "anthropic:claude-sonnet-4-5"', + " fallback: []", + "tools: []", + "runtime:", + " max_turns: 5", + "", +].join("\n"); + +/** A git repo containing an agent, with one commit. `sub` nests the agent in a subdirectory. */ +async function makeAgentRepo(sub = ""): Promise<{ root: string; agentDir: string }> { + const root = await mkdtemp(join(tmpdir(), "gitagent-ver-")); + raw(root, "init", "-q", "-b", "main"); + raw(root, "config", "user.email", "t@example.com"); + raw(root, "config", "user.name", "Test"); + + const agentDir = sub ? join(root, sub) : root; + await mkdir(join(agentDir, "tools"), { recursive: true }); + await mkdir(join(agentDir, "memory"), { recursive: true }); + await mkdir(join(agentDir, "skills", "demo"), { recursive: true }); + await writeFile(join(agentDir, "agent.yaml"), MANIFEST, "utf-8"); + await writeFile(join(agentDir, "SOUL.md"), "I am demo.\n", "utf-8"); + await writeFile(join(agentDir, "RULES.md"), "Be nice.\n", "utf-8"); + await writeFile(join(agentDir, "tools", "a.yaml"), "name: a\n", "utf-8"); + await writeFile(join(agentDir, "memory", "MEMORY.md"), "mem v1\n", "utf-8"); + await writeFile(join(agentDir, "skills", "demo", "SKILL.md"), "# demo\n", "utf-8"); + + raw(root, "add", "-A"); + raw(root, "commit", "-qm", "init"); + return { root, agentDir }; +} + +function expectCode(fn: () => unknown, code: string): void { + assert.throws(fn, (err: any) => err?.code === code, `expected VersionError code ${code}`); +} + +const cleanup = (dir: string) => rm(dir, { recursive: true, force: true }); + +describe("version names", () => { + it("accepts ordinary names and rejects unsafe ones", () => { + for (const ok of ["v1.0", "prod-2024_06", "1", "V2"]) { + assert.doesNotThrow(() => v.validateVersionName(ok), `${ok} should be valid`); + } + for (const bad of ["", "-x", "a b", "a..b", "HEAD", "foo.lock", "a/b", "v1~", "v1^", "x".repeat(200)]) { + expectCode(() => v.validateVersionName(bad), "INVALID_NAME"); + } + }); +}); + +describe("preconditions", () => { + it("rejects a directory that is not a git repo", async () => { + const dir = await mkdtemp(join(tmpdir(), "gitagent-norepo-")); + expectCode(() => v.resolveContext(dir), "NOT_A_REPO"); + await cleanup(dir); + }); + + it("rejects a repo without agent.yaml", async () => { + const dir = await mkdtemp(join(tmpdir(), "gitagent-noyaml-")); + raw(dir, "init", "-q", "-b", "main"); + expectCode(() => v.resolveContext(dir), "NO_AGENT_YAML"); + await cleanup(dir); + }); + + it("rejects a repo with no commits", async () => { + const dir = await mkdtemp(join(tmpdir(), "gitagent-nocommit-")); + raw(dir, "init", "-q", "-b", "main"); + await writeFile(join(dir, "agent.yaml"), MANIFEST, "utf-8"); + expectCode(() => v.resolveContext(dir), "NO_COMMITS"); + await cleanup(dir); + }); + + it("reports unknown versions with the known list", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + v.saveVersion(ctx, { name: "v1.0" }); + assert.throws( + () => v.planRollback(ctx, "v9.9"), + (err: any) => err.code === "UNKNOWN_VERSION" && err.message.includes("v1.0"), + ); + await cleanup(root); + }); +}); + +describe("saveVersion", () => { + it("creates an annotated tag and lists it", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + const saved = v.saveVersion(ctx, { name: "v1.0", message: "baseline" }); + + assert.equal(saved.name, "v1.0"); + assert.equal(raw(root, "cat-file", "-t", "refs/tags/agentcfg/v1.0"), "tag"); + + const list = v.listVersions(ctx); + assert.equal(list.length, 1); + assert.equal(list[0].name, "v1.0"); + assert.equal(list[0].subject, "baseline"); + assert.equal(list[0].annotated, true); + + await cleanup(root); + }); + + it("defaults the name to the manifest version", async () => { + const { root, agentDir } = await makeAgentRepo(); + const saved = v.saveVersion(v.resolveContext(agentDir), {}); + assert.equal(saved.name, "v1.0.0"); + await cleanup(root); + }); + + it("refuses a duplicate name unless forced", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + v.saveVersion(ctx, { name: "v1.0" }); + expectCode(() => v.saveVersion(ctx, { name: "v1.0" }), "TAG_EXISTS"); + assert.doesNotThrow(() => v.saveVersion(ctx, { name: "v1.0", force: true })); + await cleanup(root); + }); + + it("refuses to tag over uncommitted config", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + await writeFile(join(agentDir, "SOUL.md"), "edited\n", "utf-8"); + assert.throws( + () => v.saveVersion(ctx, { name: "v1.0" }), + (err: any) => err.code === "DIRTY_CONFIG" && err.paths.includes("SOUL.md"), + ); + await cleanup(root); + }); + + it("refuses when the index already has staged changes", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + await writeFile(join(agentDir, "memory", "MEMORY.md"), "staged\n", "utf-8"); + raw(root, "add", "memory/MEMORY.md"); + expectCode(() => v.saveVersion(ctx, { name: "v1.0" }), "INDEX_DIRTY"); + await cleanup(root); + }); + + // The property the whole feature is built around. + it("commits config with --commit but never stages memory/", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + await writeFile(join(agentDir, "SOUL.md"), "edited soul\n", "utf-8"); + await writeFile(join(agentDir, "memory", "MEMORY.md"), "dirty memory\n", "utf-8"); + + v.saveVersion(ctx, { name: "v1.0", commit: true }); + + assert.equal(raw(root, "show", "refs/tags/agentcfg/v1.0:SOUL.md"), "edited soul"); + // The memory edit was neither staged nor committed. + assert.ok(raw(root, "status", "--porcelain", "--", "memory").includes("memory/MEMORY.md")); + + await cleanup(root); + }); +}); + +describe("planRollback", () => { + it("classifies added, deleted and modified config, and never memory/ or skills/", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + v.saveVersion(ctx, { name: "v1" }); + + await writeFile(join(agentDir, "SOUL.md"), "changed\n", "utf-8"); + await writeFile(join(agentDir, "tools", "new.yaml"), "name: new\n", "utf-8"); + await rm(join(agentDir, "RULES.md")); + await writeFile(join(agentDir, "memory", "MEMORY.md"), "mem v2\n", "utf-8"); + await mkdir(join(agentDir, "skills", "learned"), { recursive: true }); + await writeFile(join(agentDir, "skills", "learned", "SKILL.md"), "# learned\n", "utf-8"); + raw(root, "add", "-A"); + raw(root, "commit", "-qm", "changes"); + + const plan = v.planRollback(ctx, "v1"); + assert.deepEqual(plan.restore, ["RULES.md", "SOUL.md"]); + assert.deepEqual(plan.remove, ["tools/new.yaml"]); + for (const p of [...plan.restore, ...plan.remove]) { + assert.ok(!p.startsWith("memory/"), `${p} must not be in the plan`); + assert.ok(!p.startsWith("skills/"), `${p} must not be in the plan`); + } + + await cleanup(root); + }); +}); + +describe("applyRollback", () => { + it("restores config, deletes files added later, and leaves memory/ alone", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + v.saveVersion(ctx, { name: "v1" }); + + await writeFile(join(agentDir, "SOUL.md"), "changed\n", "utf-8"); + await writeFile(join(agentDir, "tools", "added-later.yaml"), "name: later\n", "utf-8"); + await rm(join(agentDir, "RULES.md")); + await writeFile(join(agentDir, "memory", "MEMORY.md"), "mem AFTER tag\n", "utf-8"); + raw(root, "add", "-A"); + raw(root, "commit", "-qm", "changes"); + const before = raw(root, "rev-parse", "HEAD"); + + const result = v.applyRollback(ctx, v.planRollback(ctx, "v1")); + assert.equal(result.changed, true); + + assert.equal(await readFile(join(agentDir, "SOUL.md"), "utf-8"), "I am demo.\n"); + assert.equal(await readFile(join(agentDir, "RULES.md"), "utf-8"), "Be nice.\n"); + // A plain `git checkout -- tools/` would leave this behind. + assert.equal(existsSync(join(agentDir, "tools", "added-later.yaml")), false); + assert.ok(!raw(root, "ls-files").includes("added-later.yaml")); + // The memory formed after the tag survives. + assert.equal(await readFile(join(agentDir, "memory", "MEMORY.md"), "utf-8"), "mem AFTER tag\n"); + + // Forward-only: one new commit, nothing rewritten, still on main. + assert.equal(raw(root, "rev-list", "--count", `${before}..HEAD`), "1"); + assert.doesNotThrow(() => raw(root, "merge-base", "--is-ancestor", before, "HEAD")); + assert.equal(raw(root, "rev-parse", "--abbrev-ref", "HEAD"), "main"); + + await cleanup(root); + }); + + it("is a no-op when config already matches, with no empty commit", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + v.saveVersion(ctx, { name: "v1" }); + const count = raw(root, "rev-list", "--count", "HEAD"); + + const result = v.applyRollback(ctx, v.planRollback(ctx, "v1")); + assert.equal(result.changed, false); + assert.equal(raw(root, "rev-list", "--count", "HEAD"), count); + + await cleanup(root); + }); + + it("restores a directory that was replaced by a file of the same name", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + v.saveVersion(ctx, { name: "v1" }); + + await rm(join(agentDir, "tools"), { recursive: true }); + await writeFile(join(agentDir, "tools"), "now a file\n", "utf-8"); + raw(root, "add", "-A"); + raw(root, "commit", "-qm", "type flip"); + + v.applyRollback(ctx, v.planRollback(ctx, "v1")); + assert.equal(await readFile(join(agentDir, "tools", "a.yaml"), "utf-8"), "name: a\n"); + + await cleanup(root); + }); + + it("handles paths with spaces and quotes", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + const weird = 'we ird "quoted".yaml'; + await writeFile(join(agentDir, "tools", weird), "name: w\n", "utf-8"); + raw(root, "add", "-A"); + raw(root, "commit", "-qm", "weird path"); + v.saveVersion(ctx, { name: "v1" }); + + await rm(join(agentDir, "tools", weird)); + raw(root, "add", "-A"); + raw(root, "commit", "-qm", "remove weird"); + + const plan = v.planRollback(ctx, "v1"); + assert.deepEqual(plan.restore, [`tools/${weird}`]); + v.applyRollback(ctx, plan); + assert.equal(existsSync(join(agentDir, "tools", weird)), true); + + await cleanup(root); + }); + + it("refuses a rollback that would overwrite uncommitted config unless forced", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + v.saveVersion(ctx, { name: "v1" }); + await writeFile(join(agentDir, "SOUL.md"), "uncommitted\n", "utf-8"); + + expectCode(() => v.assertRollbackReady(ctx, false), "DIRTY_CONFIG"); + assert.doesNotThrow(() => v.assertRollbackReady(ctx, true)); + + await cleanup(root); + }); + + it("refuses to roll back on a detached HEAD unless forced", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + v.saveVersion(ctx, { name: "v1" }); + raw(root, "checkout", "-q", "--detach"); + + expectCode(() => v.assertRollbackReady(ctx, false), "DETACHED_HEAD"); + + await cleanup(root); + }); +}); + +describe("diff scope", () => { + it("never reports memory/ changes between two versions", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + v.saveVersion(ctx, { name: "v1" }); + + await writeFile(join(agentDir, "SOUL.md"), "changed\n", "utf-8"); + await writeFile(join(agentDir, "memory", "MEMORY.md"), "mem v2\n", "utf-8"); + raw(root, "add", "-A"); + raw(root, "commit", "-qm", "changes"); + v.saveVersion(ctx, { name: "v2" }); + + const args = v.buildDiffArgs(ctx, "v1", "v2", ["--name-only"]); + const out = raw(root, ...args); + assert.ok(out.includes("SOUL.md")); + assert.ok(!out.includes("memory/")); + assert.equal(v.hasConfigDiff(ctx, "v1", "v2"), true); + + await cleanup(root); + }); +}); + +describe("agent in a repo subdirectory", () => { + it("scopes save and rollback to the agent's own paths", async () => { + const { root, agentDir } = await makeAgentRepo("bot"); + await mkdir(join(root, "other"), { recursive: true }); + await writeFile(join(root, "other", "x.txt"), "untouched\n", "utf-8"); + raw(root, "add", "-A"); + raw(root, "commit", "-qm", "sibling project"); + + const ctx = v.resolveContext(agentDir); + assert.equal(ctx.prefix, "bot/"); + v.saveVersion(ctx, { name: "v1" }); + + await writeFile(join(agentDir, "SOUL.md"), "changed\n", "utf-8"); + await writeFile(join(root, "other", "x.txt"), "also changed\n", "utf-8"); + raw(root, "add", "-A"); + raw(root, "commit", "-qm", "edits"); + + const plan = v.planRollback(ctx, "v1"); + assert.deepEqual(plan.restore, ["bot/SOUL.md"]); + v.applyRollback(ctx, plan); + + assert.equal(await readFile(join(agentDir, "SOUL.md"), "utf-8"), "I am demo.\n"); + assert.equal(await readFile(join(root, "other", "x.txt"), "utf-8"), "also changed\n"); + + await cleanup(root); + }); +}); From df5a439d3bbe7f662a44cce51cd9ec3401f7b9a1 Mon Sep 17 00:00:00 2001 From: Nivesh353 Date: Wed, 9 Sep 2026 14:10:58 +0530 Subject: [PATCH 2/2] fix(version): distinguish partial-restore failure from a failed commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. A failure in the rm/checkout phase of applyRollback threw a raw GitError that escaped the CLI's VersionError handler, so the user got a stack trace and no recovery guidance while the repo sat half-mutated. - Split the failure modes: RESTORE_FAILED (plan applied partway, nothing committed, reset --hard recovers) vs COMMIT_FAILED (plan fully staged, git commit finishes it). Both now warn that reset --hard also discards uncommitted memory/, which the old message glossed over. - Drop RollbackOptions.force — it was never read; force is consumed entirely by assertRollbackReady before applyRollback is called. - Cover the rollback INDEX_DIRTY path, which had no test. Also pins that --force covers dirty config and detached HEAD but never a dirty index, since that guard is what stops staged memory/ riding along in the commit. - Note why gitStream's exit status is ignored in diff: git diff exits 1 when differences exist and hasConfigDiff already established there are some. Full suite 95 passing. --- src/version-cli.ts | 5 ++++- src/version.ts | 32 +++++++++++++++++++++++--------- test/version.test.ts | 16 ++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/version-cli.ts b/src/version-cli.ts index 6947f1d..850fb29 100644 --- a/src/version-cli.ts +++ b/src/version-cli.ts @@ -150,6 +150,8 @@ function handleDiff(agentDir: string, args: string[]): void { return; } console.log(dim(`${a} → ${b ?? "working tree"}`)); + // Exit status is intentionally ignored: git diff exits 1 when differences exist, + // and hasConfigDiff above already established that there are some. gitStream(ctx.root, buildDiffArgs(ctx, a, b, extra)); } @@ -197,7 +199,8 @@ async function handleRollback(agentDir: string, args: string[]): Promise { } } - const result = applyRollback(ctx, plan, { message: flagValue(args, "-m", "--message"), force }); + // force is consumed by assertRollbackReady above; applyRollback has no use for it. + const result = applyRollback(ctx, plan, { message: flagValue(args, "-m", "--message") }); console.log(`${green("Rolled back config to")} ${bold(name)}`); console.log( dim(`restored ${result.restored} file(s) · removed ${result.removed} file(s) · new commit ${result.commit}`), diff --git a/src/version.ts b/src/version.ts index 6bd1a28..6349258 100644 --- a/src/version.ts +++ b/src/version.ts @@ -56,6 +56,7 @@ export type VersionErrorCode = | "DIRTY_CONFIG" | "INDEX_DIRTY" | "DETACHED_HEAD" + | "RESTORE_FAILED" | "COMMIT_FAILED"; export class VersionError extends Error { @@ -385,7 +386,6 @@ export function planRollback(ctx: VersionContext, name: string): RollbackPlan { export interface RollbackOptions { message?: string; - force?: boolean; } function chunk(items: T[], size: number): T[][] { @@ -405,11 +405,24 @@ export function applyRollback( // Remove before restore: if a config path was a file at the tag and is a directory // now (or vice versa), checking out first fails with "Not a directory". - for (const paths of chunk(plan.remove, 500)) { - gitWrite(ctx.root, ["rm", "-q", "-f", "--", ...paths]); - } - for (const paths of chunk(plan.restore, 500)) { - gitWrite(ctx.root, ["checkout", tagRef(plan.name), "--", ...paths]); + // + // A failure here leaves the plan half-applied and staged but NOT committed, which + // is a different recovery story from a failed commit — hence the separate error. + try { + for (const paths of chunk(plan.remove, 500)) { + gitWrite(ctx.root, ["rm", "-q", "-f", "--", ...paths]); + } + for (const paths of chunk(plan.restore, 500)) { + gitWrite(ctx.root, ["checkout", tagRef(plan.name), "--", ...paths]); + } + } catch (err: any) { + throw new VersionError( + "RESTORE_FAILED", + `Rollback failed partway through: ${err?.message ?? err}\n` + + "Config files were partially modified and nothing was committed. " + + "Run 'git status' to inspect, then 'git reset --hard HEAD' to recover " + + "(note that also discards any other uncommitted changes, including memory/).", + ); } const subject = opts.message || `rollback config to ${plan.name}`; @@ -423,9 +436,10 @@ export function applyRollback( } catch (err: any) { throw new VersionError( "COMMIT_FAILED", - `Config was restored but the commit failed: ${err?.message ?? err}\n` + - "Changes are staged but not committed. Run 'git commit' to finish, " + - "or 'git reset --hard HEAD' to discard.", + `Config was fully restored but the commit failed: ${err?.message ?? err}\n` + + "The complete rollback is staged. Run 'git commit' to finish it, or " + + "'git reset --hard HEAD' to discard it (that also discards any other " + + "uncommitted changes, including memory/).", ); } diff --git a/test/version.test.ts b/test/version.test.ts index 9d70c78..b278c81 100644 --- a/test/version.test.ts +++ b/test/version.test.ts @@ -300,6 +300,22 @@ describe("applyRollback", () => { await cleanup(root); }); + it("refuses to roll back when the index already has staged changes", async () => { + const { root, agentDir } = await makeAgentRepo(); + const ctx = v.resolveContext(agentDir); + v.saveVersion(ctx, { name: "v1" }); + + // Staged memory/ would otherwise be swept into the rollback commit. + await writeFile(join(agentDir, "memory", "MEMORY.md"), "staged\n", "utf-8"); + raw(root, "add", "memory/MEMORY.md"); + + expectCode(() => v.assertRollbackReady(ctx, false), "INDEX_DIRTY"); + // --force covers dirty config and detached HEAD, but never a dirty index. + expectCode(() => v.assertRollbackReady(ctx, true), "INDEX_DIRTY"); + + await cleanup(root); + }); + it("refuses to roll back on a detached HEAD unless forced", async () => { const { root, agentDir } = await makeAgentRepo(); const ctx = v.resolveContext(agentDir);