Skip to content

feat(version): tag, diff and roll back an agent's config - #109

Open
Nivesh353 wants to merge 2 commits into
open-gitagent:mainfrom
Nivesh353:feat/gitagent-versioning
Open

feat(version): tag, diff and roll back an agent's config#109
Nivesh353 wants to merge 2 commits into
open-gitagent:mainfrom
Nivesh353:feat/gitagent-versioning

Conversation

@Nivesh353

Copy link
Copy Markdown
Collaborator

Summary

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. This adds an ergonomic CLI over those primitives. No new storage: versions are annotated git tags in the agent's own repo.

gitagent version save v1.0 -m "baseline before model swap"
gitagent version list
gitagent version show v1.0
gitagent version diff 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
Subcommand 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 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

Three design decisions worth reviewing

1. Rollback is config-only — memory/ and skills/ are never touched.

src/tools/memory.ts commits on every memory save and skill_learner rewrites skills/ at runtime, so those commits interleave with config commits throughout history. A whole-tree restore would silently destroy everything the agent learned after the tag was cut.

Versioned paths: agent.yaml, SOUL.md, RULES.md, DUTIES.md, AGENTS.md, config/, tools/, hooks/, knowledge/, examples/, compliance/, agents/, workflows/, schedules/, plugins/.

2. Rollback is forward-only.

It writes a new commit rather than rewriting history, so the rollback is visible in git log and revertible like anything else. No reset --hard, no branch switching — safe with a remote or other clones.

3. Restore is diff-planned, not a blanket checkout.

A plain git checkout <tag> -- tools/ leaves files added after the tag in place, producing a merge of old and new config rather than a rollback. Instead the plan comes from git diff --name-status --no-renames -z <tag> HEAD -- <pathspec>, then remove-before-restore (that ordering also handles file↔directory flips). Because every path is computed by git under the config pathspec, it is structurally impossible for memory/ to enter the plan.

Also adds src/git.ts

Every call is execFileSync with an argv array, so the quoting/injection bug class is structurally impossible. It also handles --no-pager (otherwise diff opens a pager and appears to hang), a 64 MiB buffer, closed stdin, and per-invocation identity + gpgsign=false so commits work in a repo with no user.email without ever writing to the user's git config.

Scope note: only isGitRepo is migrated to it. The other 13 ad-hoc execSync git sites — including the unescaped commit message at src/session.ts:138 — are left alone deliberately; they are on untested hot paths, and porting them belongs in a follow-up whose diff reads as a refactor rather than being buried in a feature.

Test plan

  • npm run build clean; npm test 94 passing (29 new)
  • Establishes the temp-git-repo fixture the suite lacked, hermetic via GIT_CONFIG_GLOBAL=/dev/null so CI and dev machines agree
  • Shell-injection regression test for the bug class live in src/tools/memory.ts
  • Verified end-to-end on a real 276-commit agent repo: config reverted, file added after the tag removed, memory written after the tag survived, history forward-only with the tag intact
  • Guard rails verified: dirty config, duplicate tag, unknown version, no-op rollback (creates no empty commit), detached HEAD, staged index
  • gitagent plugin still works after the shared --dir extraction

Docs

README gains a ## Versioning & Rollback section; Documentation.md gains a full ### Version CLI reference with edge-case behavior and known limitations.

Out of scope

--push / remote tag sync, versioning memory/ or skills/, per-agent tag namespacing (agentcfg/<agent>/<version>), and porting the remaining execSync sites. Tags stay local until pushed manually — save prints the exact command.

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 <tag> HEAD`,
  then remove-before-restore. A plain `git checkout <tag> -- 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.

@shreyas-lyzr shreyas-lyzr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid feature with well-thought-out design decisions. The injection-safe git layer, the config-only scope, and the forward-only rollback are all correct. Four findings below, one of which is a real correctness bug in error handling.

Security pass: no new dependencies introduced, all git invocations go through execFileSync with argv arrays (no shell), version names are validated against a strict allowlist regex before any git call touches them. No CVEs, no secrets, no injection surfaces in the new code. Clean.

The unescaped execSync at src/session.ts:138 (acknowledged out of scope) remains the highest-risk site in the repo — worth a dedicated follow-up.

Comment thread src/version.ts
Comment thread src/version.ts Outdated
Comment thread test/version.test.ts
Comment thread src/version-cli.ts
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.

@shreyas-lyzr shreyas-lyzr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four findings addressed — the follow-up commit is clean.

RESTORE_FAILED vs COMMIT_FAILED split — exactly right. The try/catch wrapping the rm+checkout phase now catches partial-restore failures and throws a distinct RESTORE_FAILED error with accurate guidance. The commit path stays as COMMIT_FAILED. Both messages now note that reset --hard also discards uncommitted memory/, which the old message glossed over. Better than the minimal fix I suggested.

RollbackOptions.force removed — correct call. Removal (not dead-field documentation) is the right answer; the field was never semantically part of applyRollback's contract. The CLI call site is updated accordingly.

INDEX_DIRTY rollback test added — the new test covers both force=false and force=true, and explicitly locks in that --force does not bypass a dirty index. That's an important behavioral contract to pin in tests.

gitStream exit status comment — the comment is accurate and saves the next reader from the same question.

95 tests passing. No new concerns. Approving.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants