Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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<agent.yaml version>
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 [<name>]` | Tag the current config. Aliases: `tag` |
| `list` | List saved versions, newest first. Aliases: `ls` |
| `show <name>` | Version metadata, file inventory, drift since |
| `diff <a> [<b>]` | Config diff between versions or against the working tree |
| `rollback <name>` | 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
Expand Down
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
<a href="#tools">Tools</a> &bull;
<a href="#hooks">Hooks</a> &bull;
<a href="#skills">Skills</a> &bull;
<a href="#plugins">Plugins</a>
<a href="#plugins">Plugins</a> &bull;
<a href="#versioning--rollback">Versioning</a>
</p>

---
Expand Down Expand Up @@ -136,6 +137,10 @@ gitagent --repo https://github.com/org/repo "Add unit tests"
| `--sandbox` | `-s` | Run in sandbox VM |
| `--prompt <text>` | `-p` | Single-shot prompt (skip REPL) |
| `--env <name>` | `-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

Expand Down Expand Up @@ -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 [<name>]` | Tag the current config. Name defaults to `v` + `version` from `agent.yaml` |
| `list` | List saved versions (`--json` for scripting) |
| `show <name>` | Version details, file inventory, and what changed since |
| `diff <a> [<b>]` | Config diff between versions, or against the working tree |
| `rollback <name>` | Restore config from a version as a new commit |

| Flag | Applies to | Description |
|---|---|---|
| `-m <msg>` | `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, …).
Expand Down
137 changes: 137 additions & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
@@ -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}^{}`]);
}
53 changes: 32 additions & 21 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<boolean> {
try {
await access(path);
Expand Down Expand Up @@ -305,19 +312,23 @@ async function ensureRepo(dir: string, model?: string): Promise<string> {
}

async function main(): Promise<void> {
// Handle plugin subcommand: gitagent plugin <install|list|remove|...>
// 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 <save|list|show|diff|rollback>
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;
}

Expand Down
Loading